import type { JsonObject } from "../shared/shared"; import { shared } from "../shared/shared"; /** Where a pointer is, as `` sends it in `presence.cursor` and `cursor:move`: fractions of its region or, with `anchor`, of that element's box, the region it is in and the sender's clock in ms. Over a surface seen through a camera, `space: "page"` and that surface's own units instead. */ export type CursorPoint = JsonObject & { x: number; y: number; t?: number; region?: string; space?: "page"; /** `#id` of the nearest ancestor with a token id, or `""` for the region itself, then `tag:n` steps down to the element. */ anchor?: string[]; dx?: number; dy?: number; }; /** One of the six emoji, sent from where the pointer was. */ export type Emote = CursorPoint & { emoji: string }; /** The point two hands meet at, and whose hand the sender's meets. */ export type HighFive = CursorPoint & { with: string }; /** The sender's wave starting or ending, in the region it started in. */ export interface Wave { region?: string; on: boolean; } /** Text someone selected inside a `` area, as `presence.selection`: the path of each end from that area (child indices, then the offset in the last node) and a hash of the text, so it is drawn only over the same text. */ export type TextSelection = JsonObject & { from: number[]; to: number[]; hash: string; region?: string; }; /** What an `id` must look like to travel: generated ids pass, names and titles do not. */ export const TOKEN = /^[\w-]{1,64}$/; export const MAX_STEPS = 24; /** One `tag:n` step of an anchor, and nothing a selector could trip on. */ const STEP = /^[A-Za-z][\w-]{0,63}:\d{1,6}$/; export function fraction(value: number): number { return Math.round(value * 1e4) / 1e4; } /** The region key a point or a wave carries, when it has one. */ export function regionOf(region: string | undefined): JsonObject { return region === undefined ? {} : { region }; } /** The units a `` reads and writes: its region's fractions, or with a `space`, the surface's own units tagged `"page"`. */ export type Units = "page" | undefined; /** `value` when it is a cursor point in `region` and `units` as `` sends one; an anchor this page cannot walk is left out. */ export function pointOf( value: unknown, region: string | undefined, units: Units = undefined, ): CursorPoint | undefined { const point = value as CursorPoint | null; if ( !point || typeof point !== "object" || !Number.isFinite(point.x) || !Number.isFinite(point.y) || point.region !== region || point.space !== units ) { return undefined; } if ( isAnchor(point.anchor) && typeof point.dx === "number" && typeof point.dy === "number" ) { return point; } const { anchor, dx, dy, ...fractions } = point; return fractions as CursorPoint; } /** `payload` when it is a `cursor:emote` in `region` and `units`. */ export function emoteOf( payload: unknown, region: string | undefined, units: Units, ): Emote | undefined { const point = pointOf(payload, region, units); return typeof point?.emoji === "string" ? (point as Emote) : undefined; } /** `payload` when it is a `cursor:high-five` in `region` and `units`. */ export function highFiveOf( payload: unknown, region: string | undefined, units: Units, ): HighFive | undefined { const point = pointOf(payload, region, units); return typeof point?.with === "string" ? (point as HighFive) : undefined; } /** `payload` when it is a `cursor:wave` in `region`. */ export function waveOf( payload: unknown, region: string | undefined, ): Wave | undefined { const wave = payload as Wave | null; return wave && typeof wave === "object" && wave.region === region && typeof wave.on === "boolean" ? wave : undefined; } /** The presence's cursor while it is in `region` and `units`. */ export function cursorOf( presence: JsonObject, region: string | undefined, units: Units = undefined, ): CursorPoint | undefined { return pointOf(presence.cursor, region, units); } /** The presence's chat message while they are typing one. */ export function messageOf(presence: JsonObject): string | undefined { const message = presence.message; return typeof message === "string" && message !== "" ? message : undefined; } /** The presence's selection when it has the shape `` writes and is in `region`. */ export function selectionOf( presence: JsonObject, region: string | undefined, ): TextSelection | undefined { const selection = presence.selection as TextSelection | null | undefined; return typeof selection === "object" && selection !== null && isPath(selection.from) && isPath(selection.to) && typeof selection.hash === "string" && selection.region === region ? selection : undefined; } /** My three presence keys as I have them now; every publish sends all three and leaves the rest of my presence alone. */ const mine: { cursor?: CursorPoint; message?: string; selection?: TextSelection; } = {}; /** Notes where my pointer is without publishing it; the next publish carries it. */ export function moved(cursor: CursorPoint): void { mine.cursor = cursor; } /** Sets the given keys of mine, `undefined` taking one away, and publishes all of them. */ export function publish(update: Partial = {}): void { Object.assign(mine, update); shared.me.setPresence(({ cursor, message, selection, ...rest }) => ({ ...rest, ...(mine.cursor && { cursor: mine.cursor }), ...(mine.message && { message: mine.message }), ...(mine.selection && { selection: mine.selection }), })); } /** Whether an anchor another page sent has the shape and size this page will walk. */ function isAnchor(value: unknown): value is string[] { if (!Array.isArray(value) || value.length === 0 || value.length > MAX_STEPS) { return false; } const [root, ...steps] = value as unknown[]; return ( (root === "" || (typeof root === "string" && root.startsWith("#") && TOKEN.test(root.slice(1)))) && steps.every((step) => typeof step === "string" && STEP.test(step)) ); } function isPath(value: unknown): value is number[] { return ( Array.isArray(value) && value.length > 0 && value.every((index) => Number.isInteger(index) && index >= 0) ); }