317 lines
16 KiB
TypeScript
317 lines
16 KiB
TypeScript
import type { JsonObject } from "./shared";
|
||||
|
|
import type { CSSProperties, InputHTMLAttributes, ReactElement, ReactNode, RefObject, TextareaHTMLAttributes } from "react";
|
|||
|
|
|
|||
|
|
/** A person, the same shape as the ClickUp kit's `User`. */
|
|||
|
|
interface User {
|
|||
|
|
kind: "user";
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
/** Link to the person's profile; empty when the host does not name one. */
|
|||
|
|
url: string;
|
|||
|
|
/** Avatar URL. */
|
|||
|
|
image?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Where a pointer is, as `<Cursors>` 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. */
|
|||
|
|
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. */
|
|||
|
|
type Emote = CursorPoint & {
|
|||
|
|
emoji: string;
|
|||
|
|
};
|
|||
|
|
/** The point two hands meet at, and whose hand the sender's meets. */
|
|||
|
|
type HighFive = CursorPoint & {
|
|||
|
|
with: string;
|
|||
|
|
};
|
|||
|
|
/** The sender's wave starting or ending, in the region it started in. */
|
|||
|
|
interface Wave {
|
|||
|
|
region?: string;
|
|||
|
|
on: boolean;
|
|||
|
|
}
|
|||
|
|
/** Text someone selected inside a `<Cursors>` 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. */
|
|||
|
|
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. */
|
|||
|
|
declare const TOKEN: RegExp;
|
|||
|
|
declare const MAX_STEPS = 24;
|
|||
|
|
declare function fraction(value: number): number;
|
|||
|
|
/** The region key a point or a wave carries, when it has one. */
|
|||
|
|
declare function regionOf(region: string | undefined): JsonObject;
|
|||
|
|
/** The units a `<Cursors>` reads and writes: its region's fractions, or with a `space`, the surface's own units tagged `"page"`. */
|
|||
|
|
type Units = "page" | undefined;
|
|||
|
|
/** `value` when it is a cursor point in `region` and `units` as `<Cursors>` sends one; an anchor this page cannot walk is left out. */
|
|||
|
|
declare function pointOf(value: unknown, region: string | undefined, units?: Units): CursorPoint | undefined;
|
|||
|
|
/** `payload` when it is a `cursor:emote` in `region` and `units`. */
|
|||
|
|
declare function emoteOf(payload: unknown, region: string | undefined, units: Units): Emote | undefined;
|
|||
|
|
/** `payload` when it is a `cursor:high-five` in `region` and `units`. */
|
|||
|
|
declare function highFiveOf(payload: unknown, region: string | undefined, units: Units): HighFive | undefined;
|
|||
|
|
/** `payload` when it is a `cursor:wave` in `region`. */
|
|||
|
|
declare function waveOf(payload: unknown, region: string | undefined): Wave | undefined;
|
|||
|
|
/** The presence's cursor while it is in `region` and `units`. */
|
|||
|
|
declare function cursorOf(presence: JsonObject, region: string | undefined, units?: Units): CursorPoint | undefined;
|
|||
|
|
/** The presence's chat message while they are typing one. */
|
|||
|
|
declare function messageOf(presence: JsonObject): string | undefined;
|
|||
|
|
/** The presence's selection when it has the shape `<Cursors>` writes and is in `region`. */
|
|||
|
|
declare function selectionOf(presence: JsonObject, region: string | undefined): TextSelection | undefined;
|
|||
|
|
/** My three presence keys as I have them now; every publish sends all three and leaves the rest of my presence alone. */
|
|||
|
|
declare const mine: {
|
|||
|
|
cursor?: CursorPoint;
|
|||
|
|
message?: string;
|
|||
|
|
selection?: TextSelection;
|
|||
|
|
};
|
|||
|
|
/** Notes where my pointer is without publishing it; the next publish carries it. */
|
|||
|
|
declare function moved(cursor: CursorPoint): void;
|
|||
|
|
/** Sets the given keys of mine, `undefined` taking one away, and publishes all of them. */
|
|||
|
|
declare function publish(update?: Partial<typeof mine>): void;
|
|||
|
|
|
|||
|
|
/** A position in px of the surface a cursor is drawn on. */
|
|||
|
|
interface Point {
|
|||
|
|
x: number;
|
|||
|
|
y: number;
|
|||
|
|
}
|
|||
|
|
/** The surface a cursor is drawn on: the units of the points it reads and writes, and where such a point falls on it, in px, as it lays out now. */
|
|||
|
|
interface Space {
|
|||
|
|
readonly units: Units;
|
|||
|
|
resolve(sample: CursorPoint): Point;
|
|||
|
|
/** Whether the viewer looks at the surface through a camera of their own; a target that moved since last frame is then followed at once rather than eased. */
|
|||
|
|
readonly camera?: boolean;
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Replays one person's cursor samples on the sender's clock, behind by a
|
|||
|
|
* delay that grows when a sample lands late and shrinks every two seconds.
|
|||
|
|
* Between samples it moves straight, and whatever the target moves that the
|
|||
|
|
* pointer did not becomes an offset that decays instead of a jump; through a
|
|||
|
|
* camera the cursor stays on the artwork.
|
|||
|
|
*/
|
|||
|
|
declare class Glide {
|
|||
|
|
private samples;
|
|||
|
|
/** Local clock minus sender clock, the smallest seen: the least delayed delivery. */
|
|||
|
|
private offset;
|
|||
|
|
private delay;
|
|||
|
|
/** The smallest lead the buffer had when a sample landed since the last review. */
|
|||
|
|
private lead;
|
|||
|
|
private reviewedAt;
|
|||
|
|
private lastNow;
|
|||
|
|
/** What was aimed at last frame, to tell a shift of this page from the pointer's own motion. */
|
|||
|
|
private aimed;
|
|||
|
|
/** The offset still to decay and its velocity. */
|
|||
|
|
private residual;
|
|||
|
|
private residualVelocity;
|
|||
|
|
/** Adds a sample as it lands; without a sender clock its arrival time stands in. Starts over when the sender's clock goes backwards or a sample lands more than the longest delay sooner than any before. */
|
|||
|
|
push(point: CursorPoint, arrivedAt: number): void;
|
|||
|
|
/** Where to draw at local time `now` on `space`, or undefined before the first sample. */
|
|||
|
|
draw(now: number, space: Space): Point | undefined;
|
|||
|
|
/** Forgets the offset being decayed. */
|
|||
|
|
rest(): void;
|
|||
|
|
/** The hops of the anchor crossings between sender times `from` and `to`: how far each lies from where the pointer's own step would have carried the cursor. */
|
|||
|
|
private hopped;
|
|||
|
|
/** The position at sender time `T`: linear between the samples around it, inside one anchor along the fractions of its box, across anchors by the pointer's own step from the earlier sample. */
|
|||
|
|
private place;
|
|||
|
|
/** The samples around sender time `T`: `p1` at or before it, `p2` after it when there is one, and how far between them `T` lies. */
|
|||
|
|
private locate;
|
|||
|
|
private playhead;
|
|||
|
|
/** Grows the delay by how far behind the playhead this sample landed, if it did, and reviews the delay every two seconds. */
|
|||
|
|
private adapt;
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Replays one collaborator's cursor on a surface, gliding (or jumping under
|
|||
|
|
* reduced motion) and fading `IDLE_MS` after the last sample; each frame's
|
|||
|
|
* position goes to `onFrame`, undefined once faded. `stop()` only stops: the
|
|||
|
|
* host removes what it drew.
|
|||
|
|
*/
|
|||
|
|
declare class Replay {
|
|||
|
|
private readonly space;
|
|||
|
|
private readonly onFrame;
|
|||
|
|
private readonly glide;
|
|||
|
|
private frame;
|
|||
|
|
private idle;
|
|||
|
|
private last;
|
|||
|
|
private faded;
|
|||
|
|
/** The newest sample, drawn as is under reduced motion. */
|
|||
|
|
private rest;
|
|||
|
|
constructor(space: Space, onFrame: (at: Point | undefined) => void);
|
|||
|
|
follow(point: CursorPoint): void;
|
|||
|
|
/** Shows the cursor where it was, draws it every frame, and starts the `IDLE_MS` to fading it. */
|
|||
|
|
wake(): void;
|
|||
|
|
stop(): void;
|
|||
|
|
private readonly fade;
|
|||
|
|
/** Draws where the samples fall on the surface now, each frame while the cursor shows. */
|
|||
|
|
private readonly draw;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* How a `<Cursors>` over a surface each viewer looks at through their own
|
|||
|
|
* camera (an `<svg viewBox>`, a `<canvas>`, a panned and zoomed layer) maps
|
|||
|
|
* the viewport to the units positions are shared in, and back; both are pure
|
|||
|
|
* and read as the camera stands at the moment of the call.
|
|||
|
|
*/
|
|||
|
|
export interface CursorSpace {
|
|||
|
|
/** A point of my viewport as the units I publish: the chart, page or world coordinate under it. */
|
|||
|
|
toContent(clientX: number, clientY: number): Point;
|
|||
|
|
/** A published point as client px, where my camera shows it now. */
|
|||
|
|
toClient(x: number, y: number): Point;
|
|||
|
|
}
|
|||
|
|
/** A surface seen through a camera: a wire point falls where the host's camera shows its units now, in px of the overlay. */
|
|||
|
|
declare class CameraSpace implements Space {
|
|||
|
|
private readonly overlay;
|
|||
|
|
private readonly space;
|
|||
|
|
readonly units: Units;
|
|||
|
|
readonly camera = true;
|
|||
|
|
constructor(overlay: () => HTMLElement, space: () => CursorSpace);
|
|||
|
|
resolve(point: CursorPoint): Point;
|
|||
|
|
}
|
|||
|
|
declare function finite(at: Point): Point | undefined;
|
|||
|
|
|
|||
|
|
export interface Cursor {
|
|||
|
|
user: User;
|
|||
|
|
/** Their colour, the same as their `Avatar`. */
|
|||
|
|
color: string;
|
|||
|
|
presence: JsonObject;
|
|||
|
|
/** The default arrow and name, to decorate or to return for some people. */
|
|||
|
|
arrow: ReactNode;
|
|||
|
|
}
|
|||
|
|
export interface CursorsProps {
|
|||
|
|
/** Press `/` to type a message everyone sees at your cursor; Escape closes it. On by default. */
|
|||
|
|
chat?: boolean;
|
|||
|
|
/** Press `1`–`6` to react with an emoji at your cursor (`E` shows which key is which), wiggle the pointer to wave, and rest it on someone's cursor to high five; a hint of the keys shows once. On by default. */
|
|||
|
|
emotes?: boolean;
|
|||
|
|
/** A selector for the element positions are fractions of, inside the element this covers; that element itself by default. A fixed-aspect board centred in a responsive page keeps cursors exact on every screen, and pointers beside it show beside it. */
|
|||
|
|
frame?: string;
|
|||
|
|
/** Names the space this covers, like `slide-${index}`: cursors written here carry it, and only cursors from the same region are drawn. Lets several `<Cursors>` share a page. */
|
|||
|
|
region?: string;
|
|||
|
|
/** Draws one collaborator's cursor in place of the default arrow and name. */
|
|||
|
|
render?: (cursor: Cursor) => ReactNode;
|
|||
|
|
/** Text you select here is highlighted in your colour for everyone else, where their page shows the same text. On by default. */
|
|||
|
|
selection?: boolean;
|
|||
|
|
/** For a canvas each viewer pans and zooms for themselves (an `<svg viewBox>`, a `<canvas>`, a transformed layer): `toContent(clientX, clientY)` maps my pointer to the units positions are shared in and `toClient(x, y)` maps a shared point back to my screen, so everyone's cursors ride the artwork through my camera. Positions then carry no anchor and `frame` is ignored. */
|
|||
|
|
space?: CursorSpace;
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Shows everyone else's pointer over the parent element (give it
|
|||
|
|
* `className="relative"`), or over the whole page when no parent is
|
|||
|
|
* positioned; never your own. Each cursor is an arrow with the person's name
|
|||
|
|
* in their colour that follows their movement a moment behind and fades when
|
|||
|
|
* they stop, pointing at the same element on every screen size and scroll
|
|||
|
|
* offset, at the same fraction of `frame`, or on the same artwork through
|
|||
|
|
* each viewer's own camera with a `space`.
|
|||
|
|
*/
|
|||
|
|
export declare function Cursors({ chat, emotes, frame, region, render, selection, space, }: CursorsProps): ReactElement;
|
|||
|
|
|
|||
|
|
/** An open hand in `color`, its wrist on the parent's origin, and the `Puppet` that animates it as the ref; `arrow` is the arrow the hand stands in for. */
|
|||
|
|
declare const HandSlot: import("react").ForwardRefExoticComponent<{
|
|||
|
|
arrow?: RefObject<Element>;
|
|||
|
|
color: string;
|
|||
|
|
} & import("react").RefAttributes<Puppet>>;
|
|||
|
|
/** The hand's pose from the pointer's horizontal velocity: it swings with the motion, leans into it and squashes on each turn. */
|
|||
|
|
declare class Swing {
|
|||
|
|
private angle;
|
|||
|
|
private velocity;
|
|||
|
|
private lastX;
|
|||
|
|
private lastT;
|
|||
|
|
/** A position sample in px at time `t` in ms. */
|
|||
|
|
push(x: number, t: number): void;
|
|||
|
|
/** The transform for this frame; the velocity decays while no sample lands. */
|
|||
|
|
pose(now: number): string;
|
|||
|
|
reset(): void;
|
|||
|
|
}
|
|||
|
|
/** When the hands meet inside the clap clip. */
|
|||
|
|
declare const IMPACT_MS = 440;
|
|||
|
|
/** Drives one cursor's hand and arrow: a small hand beside the tip while a high five is on offer, the hand in place of the arrow while its owner waves, and the high-five clip. */
|
|||
|
|
declare class Puppet {
|
|||
|
|
private readonly slot;
|
|||
|
|
private readonly svg;
|
|||
|
|
private readonly arrow;
|
|||
|
|
private readonly swing;
|
|||
|
|
private readonly running;
|
|||
|
|
private state;
|
|||
|
|
/** The side a high five on offer wants the small hand on, kept through a clap. */
|
|||
|
|
private armed;
|
|||
|
|
private frame;
|
|||
|
|
private bob;
|
|||
|
|
private watchdog;
|
|||
|
|
constructor(slot: RefObject<HTMLDivElement>, svg: RefObject<SVGSVGElement>, arrow: RefObject<Element>);
|
|||
|
|
/** Shows a small hand beside the tip while a high five is on offer, on `side` of it: the side this hand will come in from. */
|
|||
|
|
arm(on: boolean, side?: 1 | -1): void;
|
|||
|
|
wave(on: boolean): void;
|
|||
|
|
/** A position sample of the hand's owner, in px at time `t`, for the swing; while waving it also keeps the hand from timing out. */
|
|||
|
|
track(x: number, t: number): void;
|
|||
|
|
/** Runs the high-five clip: the hand comes in from `side` of the meeting point `to` (relative to the tip, in px), meets the other, recoils and comes back; resolves when it is back. */
|
|||
|
|
clap(to: Point, side: 1 | -1): Promise<void>;
|
|||
|
|
stop(): void;
|
|||
|
|
/** Ends the bob, the swing and its watchdog. */
|
|||
|
|
private still;
|
|||
|
|
private readonly draw;
|
|||
|
|
/** Names the hand's state on its slot, and the glow on the arrow while a high five is on offer. */
|
|||
|
|
private mark;
|
|||
|
|
/** Animates the slot to `to`: from `from` while the hand is hidden, else from wherever its last animation left it. */
|
|||
|
|
private enter;
|
|||
|
|
/** Animates `element` through `frames`, from wherever its last animation left it. */
|
|||
|
|
private run;
|
|||
|
|
}
|
|||
|
|
/** The clap's impact, centred on the parent's origin: dots in the two colours flying out and a ring widening; `onEnd` when they are gone. */
|
|||
|
|
declare function Burst({ colors, onEnd, }: {
|
|||
|
|
colors: string[];
|
|||
|
|
onEnd: () => void;
|
|||
|
|
}): ReactElement;
|
|||
|
|
|
|||
|
|
/** One person's cursor as drawn, in em of the parent: the arrow with its tip at the origin, name and message in a pill of `colors` beside it, and the hand the `Puppet` ref drives. */
|
|||
|
|
declare const CursorGlyph: import("react").ForwardRefExoticComponent<{
|
|||
|
|
colors: {
|
|||
|
|
backgroundColor: string;
|
|||
|
|
color: string;
|
|||
|
|
};
|
|||
|
|
message: string | undefined;
|
|||
|
|
user: User;
|
|||
|
|
} & import("react").RefAttributes<Puppet>>;
|
|||
|
|
/**
|
|||
|
|
* The arrow `Cursors` draws: its tip is the top-left corner, its fill the
|
|||
|
|
* current text colour, its edge white. Size it with `className`, `h-5` say,
|
|||
|
|
* to build a cursor of your own.
|
|||
|
|
*/
|
|||
|
|
export declare function CursorArrow({ className, style, }: {
|
|||
|
|
className?: string;
|
|||
|
|
style?: CSSProperties;
|
|||
|
|
}): ReactElement;
|
|||
|
|
/** A full-size box translated to `at`, px of the surface, or left for the host to place and fade when `at` is not given; its content sits at the box's corner. */
|
|||
|
|
declare const Layer: import("react").ForwardRefExoticComponent<{
|
|||
|
|
at?: Point;
|
|||
|
|
children: ReactNode;
|
|||
|
|
} & import("react").RefAttributes<HTMLDivElement>>;
|
|||
|
|
/** What sits at my pointer while `at` is given, and for LINGER_MS after so it can pop out; `children` is told when it is leaving. */
|
|||
|
|
declare function AtPointer({ at, children, }: {
|
|||
|
|
at: Point | undefined;
|
|||
|
|
children: (leaving: boolean) => ReactNode;
|
|||
|
|
}): ReactElement | null;
|
|||
|
|
|
|||
|
|
export interface SharedTextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|||
|
|
/** The document path, like `cards/${card.id}`. */
|
|||
|
|
doc: string;
|
|||
|
|
field: string;
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* A textarea for a `sharedText()` field; read-only for any other field.
|
|||
|
|
* Edits apply to the shared text at once (IME input at composition end),
|
|||
|
|
* `onChange` fires afterwards with the resulting value, the caret survives
|
|||
|
|
* remote edits, and `value` and `defaultValue` are ignored.
|
|||
|
|
*/
|
|||
|
|
export declare function SharedTextarea({ doc, field, ...attributes }: SharedTextareaProps): ReactElement;
|
|||
|
|
export interface SharedInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|||
|
|
doc: string;
|
|||
|
|
field: string;
|
|||
|
|
}
|
|||
|
|
/** A one-line input for a `sharedText()` field; read-only for any other field. `value`, `defaultValue` and `type` are ignored: it is always `type="text"`, the one input type whose caret the binding can follow. */
|
|||
|
|
export declare function SharedInput({ doc, field, ...attributes }: SharedInputProps): ReactElement;
|