feat: bilingual support (FA/EN), IRANSans typography, RTL layout, and interactive product detail pages
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# Sync
|
||||
|
||||
Pick where a value lives by who must see it. If everyone must see it, put it in a shared document or collection (`useSharedDoc`, `useSharedCollection`). If only you must see it and it should still be there after a reload, use `useMyState(key, initial)`. If only you must see it in this tab, plain `useState` is enough. If everyone must see it right now but nobody needs it later, use presence (`useMe().setPresence`) or `useBroadcast`.
|
||||
|
||||
Never put ClickUp data (anything from `@kits/clickup`) into shared state. Every viewer can read the shared state, whatever their own ClickUp permissions are. Store only the ids your code already hardcodes and fetch the rest live. Per-user `useMyState` is fine, because only that viewer sees it.
|
||||
|
||||
The kit is in every project as `kits/sync`. Shared state is a tree of documents and collections, addressed by paths like Firestore: `game/state` is a document, `cards` is a collection, `cards/${id}/steps` is a subcollection. Every write shows up for you right away and is merged with what everyone else writes.
|
||||
|
||||
```jsx
|
||||
import { useSharedDoc, useSharedCollection, useMyState, deleteField, sharedText, increment, arrayUnion, arrayRemove, SharedInput, SharedTextarea, useMe, useCollaborators, useBroadcast, useUndo, useSharedStatus, shared } from "@kits/sync";
|
||||
|
||||
const me = useMe(); // me.user is the viewer (clickup.viewer); me.presence is what the others see of you
|
||||
const game = useSharedDoc("game/state", { phase: "lobby", paused: false }); // the defaults fill in missing fields when reading; they are never written
|
||||
game.exists; // false until set() creates the document; game.data.id is "state"
|
||||
game.set({ phase: "voting", paused: false }); // creates the document, or replaces all of it
|
||||
game.update({ phase: "results" }); // changes only these fields; throws if the document does not exist yet
|
||||
game.update((current) => ({ paused: !current.paused })); // a function gets the latest document
|
||||
|
||||
const cards = useSharedCollection("cards"); // cards.items is the list of card documents, in order
|
||||
const id = cards.add({ title: sharedText("New"), column: "todo", likes: increment(0), tags: arrayUnion() }); // adds a card at the end and returns its id; the title is text people type in together, likes is a counter, tags is a list
|
||||
cards.update(id, { [`votes.${me.user.id}`]: true }); // a dotted key sets one nested value and leaves the others alone; deleteField() removes it; { votes: {} } replaces the whole object
|
||||
cards.update(id, { title: "Generated…" }); // replaces the text's content; people can still type in it
|
||||
cards.update(id, { likes: increment(1), tags: arrayUnion("bug") }); // adds to the counter and the list without losing what others add at the same time; arrayRemove() removes
|
||||
cards.move(id, { before: otherId }); // or { after: otherId }, or an index
|
||||
useSharedCollection(open ? `cards/${open}/steps` : null); // with a null path there are no items and the methods do nothing (useSharedDoc too)
|
||||
<SharedInput doc={`cards/${id}`} field="title" />; // an input for a sharedText() field; SharedTextarea for several lines
|
||||
|
||||
me.setPresence((current) => ({ ...current, editing: id })); // everyone else sees this right away; it is never saved; a plain value would replace the whole presence
|
||||
const collaborators = useCollaborators(); // everyone else here right now, as [{ user, presence }]; who is here is [me, ...collaborators]
|
||||
const editors = useCollaborators((all) => all.filter((c) => c.presence.editing === id)); // with a selector, re-renders only when its result changes
|
||||
shared.broadcast("buzz", { team: "red" }); // sends an event to everyone else here right now; it is never saved, so latecomers never see it
|
||||
useBroadcast("buzz", (payload, from) => flash(from.name, payload.team)); // runs for each buzz someone else sends
|
||||
const [tab, setTab] = useMyState("tab", "board"); // like useState, but saved for this viewer across reloads
|
||||
const { undo, redo, canUndo, canRedo } = useUndo(); // undo and redo your own changes
|
||||
const { connection, canEdit, outdated } = useSharedStatus(); // when outdated is true a newer build is live: ask the viewer to reload
|
||||
shared.collection("cards").items; shared.doc("game/state").data; shared.me; shared.collaborators; // the same state outside React, always current, for a draw loop or a keydown listener; inside components use the hooks
|
||||
```
|
||||
|
||||
A kanban board: the columns are constants in code, the cards are one collection with a `column` field, and each title is text people type in together.
|
||||
|
||||
```jsx
|
||||
const COLUMNS = ["todo", "doing", "done"];
|
||||
export default function Board() {
|
||||
const cards = useSharedCollection("cards");
|
||||
return COLUMNS.map((column) => (
|
||||
<section key={column}>
|
||||
<h2>{column}</h2>
|
||||
{cards.items.filter((card) => card.column === column).map((card) => (
|
||||
<SharedInput key={card.id} doc={`cards/${card.id}`} field="title" />
|
||||
))}
|
||||
<button onClick={() => cards.add({ title: sharedText("New card"), column })}>Add card</button>
|
||||
</section>
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Change counters and lists with `increment()`, `arrayUnion()` and `arrayRemove()`, never with `likes + 1` or `[...tags, "bug"]`. Reading a value and writing it back loses whatever someone else wrote in between.
|
||||
- There are no transactions or locks. To decide who gets something, let everyone write their own document (`claims/${me.user.id}`) and compute the same winner in render. Store facts, like the time a timer ends, and compute the rest (the countdown, totals, scores) when rendering.
|
||||
- Keep lists flat. A kanban board is one `cards` collection where every card has a `column` field, not one list per column; the column names live in your code. To reorder, call `move()`; never store your own order arrays.
|
||||
- Name `useMyState` keys like shared paths (`draft`, `cards/42`); names starting with `#` belong to the kit.
|
||||
|
||||
To see it as two people, run `artifact cdp -c "text=Add card" --as Bob -s`: it clicks as you, then screenshots Bob's tab (`--as me` switches back).
|
||||
|
||||
Formatted text (mentions, a slash menu) everyone works on is the `Editor` of `@kits/components/editor`; it creates its own field, the COMPONENTS skill explains it.
|
||||
|
||||
The full API is in `kits/sync/docs/shared.d.ts`; the props of `SharedInput` and `SharedTextarea` are in `kits/sync/docs/components.d.ts`.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"useSharedDoc": ["collab"],
|
||||
"useSharedCollection": ["collab"],
|
||||
"useSharedStatus": ["collab"],
|
||||
"useUndo": ["collab"],
|
||||
"useMe": ["collab"],
|
||||
"useCollaborators": ["collab"],
|
||||
"useBroadcast": ["collab"],
|
||||
"shared": ["collab"],
|
||||
"sharedText": ["collab"],
|
||||
"increment": ["collab"],
|
||||
"arrayUnion": ["collab"],
|
||||
"arrayRemove": ["collab"],
|
||||
"deleteField": ["collab"],
|
||||
"Cursors": ["collab"],
|
||||
"SharedInput": ["collab"],
|
||||
"SharedTextarea": ["collab"],
|
||||
"useMyState": ["storage"],
|
||||
"myState": ["storage"],
|
||||
"internal/Reactions": ["collab"],
|
||||
"internal/opaque": ["collab"],
|
||||
"internal/opaqueField": ["collab"],
|
||||
"internal/useOpaqueField": ["collab"]
|
||||
}
|
||||
Vendored
+316
@@ -0,0 +1,316 @@
|
||||
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;
|
||||
Vendored
+206
@@ -0,0 +1,206 @@
|
||||
/** What a field can hold. Store dates as ISO strings or numbers. */
|
||||
export type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
||||
export interface JsonObject {
|
||||
[field: string]: JsonValue;
|
||||
}
|
||||
/** A document's fields plus its `id`, the last path segment; read-only. */
|
||||
export interface DocData extends JsonObject {
|
||||
id: string;
|
||||
}
|
||||
export type FieldValue = {
|
||||
"#delete": true;
|
||||
} | {
|
||||
"#text": string;
|
||||
} | {
|
||||
"#opaque": {
|
||||
y: "xml" | "map";
|
||||
of: string;
|
||||
};
|
||||
} | {
|
||||
"#increment": number;
|
||||
} | {
|
||||
"#union": JsonValue[];
|
||||
} | {
|
||||
"#remove": JsonValue[];
|
||||
};
|
||||
/**
|
||||
* The fields to write, each key on its own: a dotted key like `"votes.u1"`
|
||||
* sets one nested value, a plain object replaces the whole nested object, a
|
||||
* marker (`deleteField()`, `sharedText()`, `increment()`, `arrayUnion()`,
|
||||
* `arrayRemove()` or another kit's) creates or changes that kind of field,
|
||||
* and `undefined` is skipped. `id` is ignored, so an item can be spread back
|
||||
* in; only the fields the spread changed are written.
|
||||
*/
|
||||
export interface Fields {
|
||||
[field: string]: JsonValue | FieldValue | undefined;
|
||||
}
|
||||
/** Removes a field: `update({ [`votes.${uid}`]: deleteField() })`. */
|
||||
export declare function deleteField(): FieldValue;
|
||||
/** Creates a text everyone can type in together: `add({ body: sharedText() })`. Edit it with `SharedTextarea` or `SharedInput`; the field reads and writes as a string. */
|
||||
export declare function sharedText(initial?: string): FieldValue;
|
||||
/** Adds to a number without losing anyone else's additions: `update({ likes: increment(1) })`; negative subtracts. The field reads as a number, and writing a plain number to it adjusts it by the difference. */
|
||||
export declare function increment(by: number): FieldValue;
|
||||
/** Adds values to an array, each once, without losing anyone else's: `update({ tags: arrayUnion("bug") })`, or `arrayUnion(...tags)` for several. The field reads as an array in the order values were added, and writing a plain array to it adds and removes the difference. */
|
||||
export declare function arrayUnion(...values: JsonValue[]): FieldValue;
|
||||
/** Removes values from an array: `update({ tags: arrayRemove("bug") })`. */
|
||||
export declare function arrayRemove(...values: JsonValue[]): FieldValue;
|
||||
export interface SharedDoc {
|
||||
/** False until the document is created with `set`; `update` throws before that. */
|
||||
exists: boolean;
|
||||
data: DocData;
|
||||
/** Creates or replaces the whole document. */
|
||||
set(fields: Fields): void;
|
||||
/** Merges into the document; throws if it does not exist. */
|
||||
update(fields: Fields | ((current: DocData) => Fields)): void;
|
||||
/** Deletes the document and its subcollections; nothing happens if it does not exist. */
|
||||
delete(): void;
|
||||
}
|
||||
/**
|
||||
* One document at a path like `game/state` or `cards/k3f9`; `defaults` fill
|
||||
* in missing top-level fields and are never written. A null path gives empty
|
||||
* data and methods that do nothing; suspends until the shared state has
|
||||
* loaded.
|
||||
*/
|
||||
export declare function useSharedDoc(path: string | null | undefined, defaults?: Fields): SharedDoc;
|
||||
export interface SharedCollection {
|
||||
/** The child documents, in order, as of this render; `shared.collection(path).items` is always current. */
|
||||
items: readonly DocData[];
|
||||
/** Adds a document at the end and returns its id. Pass `id` in the fields to choose it. */
|
||||
add(fields: Fields): string;
|
||||
/** Creates or replaces the document with this `id`; returns the id. */
|
||||
set(id: string, fields: Fields): string;
|
||||
/** Merges into the item; throws if the id is unknown. */
|
||||
update(id: string, fields: Fields | ((current: DocData) => Fields)): void;
|
||||
/** Deletes the item and its subcollections; nothing happens if the id is unknown. */
|
||||
delete(id: string): void;
|
||||
/** Moves an item to an index, or before or after another item; `items` returns the new order. Throws if either id is unknown. */
|
||||
move(id: string, to: number | {
|
||||
before: string;
|
||||
} | {
|
||||
after: string;
|
||||
}): void;
|
||||
}
|
||||
/**
|
||||
* The documents at a collection path like `cards` or `cards/k3f9/steps`, in
|
||||
* order, and the methods to change them. A null path gives no items and
|
||||
* methods that do nothing; suspends until the shared state has loaded.
|
||||
*/
|
||||
export declare function useSharedCollection(path: string | null | undefined): SharedCollection;
|
||||
/** One person viewing the artifact right now: who they are and their presence. */
|
||||
export interface Collaborator {
|
||||
user: User;
|
||||
/** Their presence; `{}` until they set one. */
|
||||
presence: JsonObject;
|
||||
}
|
||||
/**
|
||||
* This viewer: `user` is `clickup.viewer`, `presence` is what everyone else
|
||||
* sees of them live, `{}` until set, never saved.
|
||||
*/
|
||||
export declare function useMe(): Collaborator & {
|
||||
/** Sets what everyone else sees of you, like a `useState` setter: pass a new value or a function of the current one. Spread the current one to keep the keys other components write: `cursor`, `message` and `selection` from `Cursors`, `slide` from a slides `Deck`. */
|
||||
setPresence(presence: JsonObject | ((current: JsonObject) => JsonObject)): void;
|
||||
};
|
||||
/**
|
||||
* Everyone else viewing the artifact right now, one entry per person. With
|
||||
* `select`, only what it returns, re-rendering when that changes:
|
||||
* `useCollaborators((all) => all.filter((c) => c.presence.editing === id))`.
|
||||
*/
|
||||
export declare function useCollaborators<T = readonly Collaborator[]>(select?: (collaborators: readonly Collaborator[]) => T): T;
|
||||
/**
|
||||
* Runs `listener` for each `name` broadcast another person sends while
|
||||
* mounted, with the payload and who sent it:
|
||||
* `useBroadcast("buzz", (payload, from) => flash(from.name))`. Broadcasts
|
||||
* are for what happens, documents for what must survive; a listener runs on
|
||||
* every other page, so a write in it lands once per viewer.
|
||||
*/
|
||||
export declare function useBroadcast(name: string, listener: (payload: JsonValue, from: User) => void): void;
|
||||
/**
|
||||
* Undo and redo this viewer's own changes; all writes from one event handler
|
||||
* are one step. While `canEdit` is false, both do nothing and both flags are
|
||||
* false.
|
||||
*/
|
||||
export declare function useUndo(): {
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
};
|
||||
export interface SharedStatus {
|
||||
/** `connecting` until the shared state has loaded, and again while reconnecting; `disconnected` on a host without collaboration; otherwise the host's connection state. */
|
||||
connection: "connecting" | "connected" | "disconnected";
|
||||
/** False while loading, in read-only rooms and in outdated builds. Writes are then ignored. */
|
||||
canEdit: boolean;
|
||||
/** True when a newer build of this artifact is live. Ask the viewer to reload. */
|
||||
outdated: boolean;
|
||||
}
|
||||
/** Whether the shared state is connected, whether you can edit it, and whether a newer build is live. */
|
||||
export declare function useSharedStatus(): SharedStatus;
|
||||
/**
|
||||
* Shared state outside React: the same documents, collections, presence,
|
||||
* broadcasts, undo and status as the hooks, always current. Use it in a draw
|
||||
* loop or in an event listener set up outside a component; in render, use
|
||||
* the hooks.
|
||||
*/
|
||||
export declare const shared: {
|
||||
/** The document at `path`, as `useSharedDoc` gives it; `data` and `exists` read the current state. */
|
||||
doc(path: string | null | undefined, defaults?: Fields): SharedDoc;
|
||||
/** The collection at `path`, as `useSharedCollection` gives it; `items` reads the current state. */
|
||||
collection(path: string | null | undefined): SharedCollection;
|
||||
/** This viewer, as `useMe` gives them. */
|
||||
readonly me: Collaborator & {
|
||||
setPresence(presence: JsonObject | ((current: JsonObject) => JsonObject)): void;
|
||||
};
|
||||
readonly collaborators: readonly Collaborator[];
|
||||
/** Sends `name` with `payload` to everyone else viewing the artifact right now; never saved, never replayed to anyone who arrives later. */
|
||||
broadcast(name: string, payload?: JsonValue): void;
|
||||
/** Calls `listener` with each `name` broadcast another person sends, as `useBroadcast` does. Returns a function that stops the calls. */
|
||||
onBroadcast(name: string, listener: (payload: JsonValue, from: User) => void): () => void;
|
||||
readonly status: SharedStatus;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
readonly canUndo: boolean;
|
||||
readonly canRedo: boolean;
|
||||
/** Calls `listener` after every change to documents, presence, status or history. Returns a function that stops the calls. */
|
||||
subscribe(listener: () => void): () => void;
|
||||
/** Resolves once the shared state has loaded; until then writes are ignored. */
|
||||
ready(): Promise<void>;
|
||||
};
|
||||
|
||||
/** A person, the same shape as the ClickUp kit's `User`. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** A stored value of mine outside React: `get()`, `set()` and `remove()`. */
|
||||
export interface MyState<T> {
|
||||
/** The stored value, or `initial` while nothing is stored. */
|
||||
get(): T;
|
||||
/** Replaces the value; a function receives the current one, like a `useState` setter. */
|
||||
set(next: T | ((current: T) => T)): void;
|
||||
/** Deletes the key; `get()` returns `initial` again. */
|
||||
remove(): void;
|
||||
}
|
||||
/**
|
||||
* A value of mine that survives a reload, `useState` with a key:
|
||||
* `const [tab, setTab] = useMyState("tab", "board")`, stored per viewer per
|
||||
* artifact as JSON, up to 4,000 characters per key (`set` throws past that).
|
||||
* Suspends until the stored value has loaded, so the first render never shows
|
||||
* `initial` over a stored value; a key mounted later needs its own
|
||||
* `<Suspense>`.
|
||||
*/
|
||||
export declare function useMyState<T>(key: string, initial: T): [T, (next: T | ((current: T) => T)) => void];
|
||||
/**
|
||||
* The same value outside React, always current: `myState("tab", "board").get()`
|
||||
* in a draw loop or event listener, `.set(v)` to write it, `.remove()` to
|
||||
* delete the key. Starts loading the value when called.
|
||||
*/
|
||||
export declare function myState<T>(key: string, initial: T): MyState<T>;
|
||||
export declare function myState<T>(key: string): MyState<T | undefined>;
|
||||
/** A value the kit keeps for this viewer under a `#` key, which authors are refused; resolves once the stored value has loaded. */
|
||||
declare function kitState<T>(key: `#${string}`, initial: T): Promise<MyState<T>>;
|
||||
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
CursorArrow,
|
||||
Cursors,
|
||||
SharedInput,
|
||||
SharedTextarea,
|
||||
arrayRemove,
|
||||
arrayUnion,
|
||||
deleteField,
|
||||
increment,
|
||||
myState,
|
||||
shared,
|
||||
sharedText,
|
||||
useBroadcast,
|
||||
useCollaborators,
|
||||
useMe,
|
||||
useMyState,
|
||||
useSharedCollection,
|
||||
useSharedDoc,
|
||||
useSharedStatus,
|
||||
useUndo,
|
||||
} from "./index.ts";
|
||||
@@ -0,0 +1,27 @@
|
||||
/** The ClickUp avatar colours with a readable text colour each, in the same order. */
|
||||
export const COLORS: ReadonlyArray<{ backgroundColor: string; color: string }> =
|
||||
[
|
||||
{ backgroundColor: "#6647f0", color: "#ffffff" },
|
||||
{ backgroundColor: "#3e63dd", color: "#ffffff" },
|
||||
{ backgroundColor: "#0083e6", color: "#ffffff" },
|
||||
{ backgroundColor: "#109585", color: "#ffffff" },
|
||||
{ backgroundColor: "#248f7d", color: "#ffffff" },
|
||||
{ backgroundColor: "#ffc53d", color: "#111827" },
|
||||
{ backgroundColor: "#de5e07", color: "#ffffff" },
|
||||
{ backgroundColor: "#e5484d", color: "#ffffff" },
|
||||
{ backgroundColor: "#e93d82", color: "#ffffff" },
|
||||
{ backgroundColor: "#ab4aba", color: "#ffffff" },
|
||||
{ backgroundColor: "#a18072", color: "#ffffff" },
|
||||
];
|
||||
|
||||
/** Background and text colour for a user id, the pair `Cursors` and ClickUp avatars use. */
|
||||
export function colorOf(id: string): {
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
} {
|
||||
let hash = 5381;
|
||||
for (const char of id) {
|
||||
hash = (hash * 33) ^ char.charCodeAt(0);
|
||||
}
|
||||
return COLORS[Math.abs(hash) % COLORS.length]!;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { Cursor, CursorSpace, CursorsProps } from "../cursors/Cursors";
|
||||
export { Cursors } from "../cursors/Cursors";
|
||||
export { CursorArrow } from "../cursors/glyph";
|
||||
export type { SharedInputProps, SharedTextareaProps } from "./sharedText";
|
||||
export { SharedInput, SharedTextarea } from "./sharedText";
|
||||
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ChangeEventHandler,
|
||||
CompositionEvent,
|
||||
CompositionEventHandler,
|
||||
InputHTMLAttributes,
|
||||
ReactElement,
|
||||
TextareaHTMLAttributes,
|
||||
} from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { documentPath, validateName } from "../shared/paths";
|
||||
import { getStore, STATUS_KEY } from "../shared/store";
|
||||
import { useStoreValue } from "../shared/useStoreValue";
|
||||
import { warnOnce } from "../shared/warn";
|
||||
|
||||
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 function SharedTextarea({
|
||||
doc,
|
||||
field,
|
||||
...attributes
|
||||
}: SharedTextareaProps): ReactElement {
|
||||
const control = useTextControl<HTMLTextAreaElement>(doc, field, attributes);
|
||||
return <textarea {...attributes} {...control} />;
|
||||
}
|
||||
|
||||
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 function SharedInput({
|
||||
doc,
|
||||
field,
|
||||
...attributes
|
||||
}: SharedInputProps): ReactElement {
|
||||
const control = useTextControl<HTMLInputElement>(doc, field, attributes);
|
||||
return <input {...attributes} {...control} type="text" />;
|
||||
}
|
||||
|
||||
type TextElement = HTMLTextAreaElement | HTMLInputElement;
|
||||
|
||||
/** The element props the control wraps rather than passes through. */
|
||||
interface Wrapped<E extends TextElement> {
|
||||
readOnly?: boolean;
|
||||
onChange?: ChangeEventHandler<E>;
|
||||
onCompositionStart?: CompositionEventHandler<E>;
|
||||
onCompositionEnd?: CompositionEventHandler<E>;
|
||||
}
|
||||
|
||||
/** The props that bind an element to the text; spread them after its own attributes, whose `value` and `defaultValue` they replace. */
|
||||
function useTextControl<E extends TextElement>(
|
||||
doc: string,
|
||||
field: string,
|
||||
{ readOnly, onChange, onCompositionStart, onCompositionEnd }: Wrapped<E>,
|
||||
) {
|
||||
const store = getStore();
|
||||
const path = documentPath(doc);
|
||||
validateName(field, "A field name");
|
||||
store.suspendUntilSynced();
|
||||
const text = useMemo(() => store.text(path, field), [store, path, field]);
|
||||
const value = useStoreValue(path, text.value);
|
||||
const editable = useStoreValue(path, text.editable);
|
||||
const canEdit =
|
||||
useStoreValue(STATUS_KEY, () => store.status.canEdit) && editable;
|
||||
const [composing, setComposing] = useState<string | null>(null);
|
||||
const element = useRef<E>(null);
|
||||
const selection = useRef<{ start: unknown; end: unknown } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editable && store.data(path)) {
|
||||
const held = store.opaqueAt(path, field);
|
||||
warnOnce(
|
||||
`text:${path}:${field}`,
|
||||
held
|
||||
? `"${field}" on "${path}" is a ${held.of} field, not a shared text; edit it with the @kits/components component that created it.`
|
||||
: `"${field}" on "${path}" is not a shared text; create it with sharedText().`,
|
||||
);
|
||||
}
|
||||
}, [editable, store, path, field]);
|
||||
|
||||
const remember = useCallback(() => {
|
||||
const control = element.current;
|
||||
if (control && document.activeElement === control) {
|
||||
selection.current = {
|
||||
start: text.anchor(control.selectionStart ?? 0),
|
||||
end: text.anchor(control.selectionEnd ?? 0),
|
||||
};
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("selectionchange", remember);
|
||||
return () => document.removeEventListener("selectionchange", remember);
|
||||
}, [remember]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const control = element.current;
|
||||
const remembered = selection.current;
|
||||
if (!control || !remembered || document.activeElement !== control) {
|
||||
return;
|
||||
}
|
||||
const start = text.indexOf(remembered.start);
|
||||
const end = text.indexOf(remembered.end);
|
||||
if (start !== undefined && end !== undefined) {
|
||||
const length = control.value.length;
|
||||
control.setSelectionRange(
|
||||
Math.min(start, length),
|
||||
Math.min(Math.max(start, end), length),
|
||||
);
|
||||
}
|
||||
}, [text, value]);
|
||||
|
||||
const apply = (control: E) => {
|
||||
text.edit(control.value, control.selectionEnd ?? control.value.length);
|
||||
remember();
|
||||
};
|
||||
|
||||
const handleChange = (event: ChangeEvent<E>) => {
|
||||
if ((event.nativeEvent as InputEvent).isComposing || composing !== null) {
|
||||
setComposing(event.target.value);
|
||||
return;
|
||||
}
|
||||
apply(event.target);
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleCompositionStart = (event: CompositionEvent<E>) => {
|
||||
setComposing(event.currentTarget.value);
|
||||
onCompositionStart?.(event);
|
||||
};
|
||||
|
||||
const handleCompositionEnd = (event: CompositionEvent<E>) => {
|
||||
setComposing(null);
|
||||
apply(event.currentTarget);
|
||||
onChange?.(event as unknown as ChangeEvent<E>);
|
||||
onCompositionEnd?.(event);
|
||||
};
|
||||
|
||||
return {
|
||||
ref: element,
|
||||
value: composing ?? value,
|
||||
defaultValue: undefined,
|
||||
readOnly: readOnly || !canEdit,
|
||||
onChange: handleChange,
|
||||
onCompositionStart: handleCompositionStart,
|
||||
onCompositionEnd: handleCompositionEnd,
|
||||
onSelect: remember,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { colorOf } from "../colors";
|
||||
import { kitState } from "../shared/myState";
|
||||
import type { Collaborator, JsonObject } from "../shared/shared";
|
||||
import { shared, useBroadcast, useCollaborators } from "../shared/shared";
|
||||
import { warnOnce } from "../shared/warn";
|
||||
import type { User } from "../user";
|
||||
import { anchorAt, areaOf, DomSpace } from "./anchor";
|
||||
import type { CursorSpace } from "./camera";
|
||||
import { CameraSpace, finite } from "./camera";
|
||||
import { AtPointer, CursorGlyph, Layer } from "./glyph";
|
||||
import type { Puppet } from "./hand";
|
||||
import type { Point, Space } from "./motion";
|
||||
import { Replay } from "./motion";
|
||||
import type { Peer, Pointer } from "./reactions";
|
||||
import { plainKey, Reactions } from "./reactions";
|
||||
import { paint, rangeOf, selectionIn } from "./selection";
|
||||
import {
|
||||
BESIDE_ARROW,
|
||||
BUBBLE,
|
||||
cx,
|
||||
KEY,
|
||||
LINGER_MS,
|
||||
PILL,
|
||||
POP_IN,
|
||||
POP_OUT,
|
||||
} from "./tokens";
|
||||
import type { CursorPoint, Units } from "./wire";
|
||||
import {
|
||||
cursorOf,
|
||||
fraction,
|
||||
messageOf,
|
||||
moved,
|
||||
pointOf,
|
||||
publish,
|
||||
regionOf,
|
||||
selectionOf,
|
||||
} from "./wire";
|
||||
|
||||
export type { CursorSpace } from "./camera";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** What sits at my pointer: the chat input, or the legend where it opened. */
|
||||
type Mode = "chat" | { legend: Point };
|
||||
|
||||
/** How long a pointer holds still before where it is counts as where it rests. */
|
||||
const REST_MS = 150;
|
||||
const CHAT_IDLE_MS = 5_000;
|
||||
const CHAT_PLACEHOLDER = "Say something…";
|
||||
const HINT_MS = 3_000;
|
||||
/** How many `<Cursors>` are mounted per region. */
|
||||
const mounted = new Map<string | undefined, number>();
|
||||
/** Pointer moves an inner region already took. */
|
||||
const claimed = new WeakSet<Event>();
|
||||
|
||||
/**
|
||||
* 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 function Cursors({
|
||||
chat = true,
|
||||
emotes = true,
|
||||
frame,
|
||||
region,
|
||||
render,
|
||||
selection = true,
|
||||
space,
|
||||
}: CursorsProps): ReactElement {
|
||||
const overlay = useRef<HTMLDivElement>(null);
|
||||
/** The element my pointer events come from. */
|
||||
const area = useRef<HTMLElement>();
|
||||
const camera = useRef(space);
|
||||
camera.current = space;
|
||||
const through = space !== undefined;
|
||||
const surface = useMemo<Space>(
|
||||
() =>
|
||||
through
|
||||
? new CameraSpace(
|
||||
() => overlay.current!,
|
||||
() => camera.current!,
|
||||
)
|
||||
: new DomSpace(() => overlay.current!),
|
||||
[through],
|
||||
);
|
||||
const others = useCollaborators(
|
||||
useMemo(() => inRegion(region, surface.units), [region, surface]),
|
||||
);
|
||||
const selections = useCollaborators(
|
||||
useMemo(() => selectionsIn(region), [region]),
|
||||
);
|
||||
const arrived = useCollaborators((all) => all.length > 0);
|
||||
const [mine, setMine] = useState<Pointer>();
|
||||
const [mode, setMode] = useState<Mode>();
|
||||
const [hinting, setHinting] = useState(false);
|
||||
/** Everyone's cursor by user id. */
|
||||
const peers = useRef(new Map<string, Peer>());
|
||||
const [placed, setPlaced] = useState(false);
|
||||
useLayoutEffect(() => setPlaced(true), []);
|
||||
const pointing = mine !== undefined;
|
||||
const legend = typeof mode === "object" ? mode.legend : undefined;
|
||||
const showLegend = useCallback(
|
||||
(at: Point | undefined) => setMode(at && { legend: at }),
|
||||
[],
|
||||
);
|
||||
|
||||
/** My pointer at `at` as the point everyone else reads: the camera's units with a `space`, else fractions of the overlay plus the element under it unless a `frame` sets the fractions. */
|
||||
const toWire = useCallback(
|
||||
(at: Point): (CursorPoint & { t: number }) | undefined => {
|
||||
const element = overlay.current!;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const t = Math.round(performance.now());
|
||||
if (surface.units === "page") {
|
||||
const content = finite(
|
||||
camera.current!.toContent(rect.left + at.x, rect.top + at.y),
|
||||
);
|
||||
return (
|
||||
content && {
|
||||
x: fraction(content.x),
|
||||
y: fraction(content.y),
|
||||
...regionOf(region),
|
||||
space: "page",
|
||||
t,
|
||||
}
|
||||
);
|
||||
}
|
||||
return {
|
||||
x: fraction(at.x / rect.width),
|
||||
y: fraction(at.y / rect.height),
|
||||
...(frame === undefined
|
||||
? anchorAt(area.current!, element, rect.left + at.x, rect.top + at.y)
|
||||
: undefined),
|
||||
...regionOf(region),
|
||||
t,
|
||||
};
|
||||
},
|
||||
[frame, region, surface],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (frame === undefined) {
|
||||
return;
|
||||
}
|
||||
if (surface.units === "page") {
|
||||
warnOnce(
|
||||
`cursors:frame:space:${frame}`,
|
||||
`<Cursors frame=${JSON.stringify(frame)}> also has a space, whose units positions are in, so the frame is ignored.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const element = overlay.current!;
|
||||
const outer = areaOf(element);
|
||||
const inner = outer.querySelector<HTMLElement>(frame);
|
||||
if (!inner) {
|
||||
warnOnce(
|
||||
`cursors:frame:${frame}`,
|
||||
`<Cursors frame=${JSON.stringify(frame)}> matches nothing inside the element it covers, so positions are fractions of that element.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const fit = () => {
|
||||
const around = outer.getBoundingClientRect();
|
||||
const box = inner.getBoundingClientRect();
|
||||
element.style.left = `${box.left - around.left - outer.clientLeft}px`;
|
||||
element.style.top = `${box.top - around.top - outer.clientTop}px`;
|
||||
element.style.width = `${box.width}px`;
|
||||
element.style.height = `${box.height}px`;
|
||||
};
|
||||
fit();
|
||||
const observer = new ResizeObserver(fit);
|
||||
observer.observe(outer);
|
||||
observer.observe(inner);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
for (const property of ["left", "top", "width", "height"]) {
|
||||
element.style.removeProperty(property);
|
||||
}
|
||||
};
|
||||
}, [frame, surface]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = overlay.current!;
|
||||
const target = areaOf(element);
|
||||
area.current = target;
|
||||
if (
|
||||
target !== document.documentElement &&
|
||||
element.parentElement !== target
|
||||
) {
|
||||
warnOnce(
|
||||
"cursors:parent",
|
||||
'<Cursors> covers a larger element than its parent because the parent is not positioned; add className="relative" to the parent to cover only it.',
|
||||
);
|
||||
}
|
||||
if ((mounted.get(region) ?? 0) > 0) {
|
||||
warnOnce(
|
||||
`cursors:twice:${region}`,
|
||||
"<Cursors> is mounted twice for one region; presence.cursor is one key, so give each a region of its own.",
|
||||
);
|
||||
}
|
||||
mounted.set(region, (mounted.get(region) ?? 0) + 1);
|
||||
let rest: ReturnType<typeof setTimeout> | undefined;
|
||||
/** Whether my pointer is over my content. */
|
||||
let here = false;
|
||||
/** Where my pointer was last, in px of the overlay, and the point it went out as. */
|
||||
let last: { at: Point; sent: CursorPoint | undefined } | undefined;
|
||||
/** The frame loop that follows my camera while my pointer rests. */
|
||||
let watching = 0;
|
||||
/** My pointer is no longer over my content. */
|
||||
const lost = () => {
|
||||
clearTimeout(rest);
|
||||
cancelAnimationFrame(watching);
|
||||
watching = 0;
|
||||
here = false;
|
||||
last = undefined;
|
||||
setMine(undefined);
|
||||
setMode(undefined);
|
||||
};
|
||||
/** Sends `point` as where I am now, and where I rest once I hold still. */
|
||||
const send = (point: CursorPoint) => {
|
||||
moved(point);
|
||||
if (cursorOf(shared.me.presence, region, surface.units) === undefined) {
|
||||
publish();
|
||||
}
|
||||
clearTimeout(rest);
|
||||
rest = setTimeout(publish, REST_MS);
|
||||
};
|
||||
const watch = () => {
|
||||
watching = 0;
|
||||
if (!here || !last) {
|
||||
return;
|
||||
}
|
||||
const point = toWire(last.at);
|
||||
if (point && (point.x !== last.sent?.x || point.y !== last.sent?.y)) {
|
||||
last.sent = point;
|
||||
send(point);
|
||||
}
|
||||
watching = requestAnimationFrame(watch);
|
||||
};
|
||||
const move = (event: PointerEvent) => {
|
||||
if (claimed.has(event)) {
|
||||
lost();
|
||||
return;
|
||||
}
|
||||
claimed.add(event);
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
return;
|
||||
}
|
||||
const at = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
const point = toWire(at);
|
||||
if (point) {
|
||||
shared.broadcast("cursor:move", point);
|
||||
send(point);
|
||||
}
|
||||
here = true;
|
||||
last = { at, sent: point };
|
||||
if (surface.units === "page" && watching === 0) {
|
||||
watching = requestAnimationFrame(watch);
|
||||
}
|
||||
if (chat || emotes) {
|
||||
setMine({
|
||||
...at,
|
||||
t: point?.t ?? Math.round(performance.now()),
|
||||
down: event.buttons !== 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (here && plainKey(event) === "/") {
|
||||
setMode("chat");
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
const leave = () => {
|
||||
lost();
|
||||
publish({ cursor: undefined, message: undefined });
|
||||
};
|
||||
const selecting = () =>
|
||||
selectionOf(shared.me.presence, region) !== undefined;
|
||||
const select = () => {
|
||||
const selected = selectionIn(target, region);
|
||||
if (selected || selecting()) {
|
||||
publish({ selection: selected });
|
||||
}
|
||||
};
|
||||
const hidden = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
lost();
|
||||
publish({
|
||||
cursor: undefined,
|
||||
message: undefined,
|
||||
selection: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
target.addEventListener("pointermove", move);
|
||||
target.addEventListener("pointerleave", leave);
|
||||
document.addEventListener("visibilitychange", hidden);
|
||||
if (selection) {
|
||||
document.addEventListener("selectionchange", select);
|
||||
}
|
||||
if (chat) {
|
||||
window.addEventListener("keydown", onKey);
|
||||
}
|
||||
return () => {
|
||||
target.removeEventListener("pointermove", move);
|
||||
target.removeEventListener("pointerleave", leave);
|
||||
document.removeEventListener("visibilitychange", hidden);
|
||||
document.removeEventListener("selectionchange", select);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
mounted.set(region, mounted.get(region)! - 1);
|
||||
lost();
|
||||
if (cursorOf(shared.me.presence, region, surface.units) !== undefined) {
|
||||
publish({ cursor: undefined, message: undefined });
|
||||
}
|
||||
if (selecting()) {
|
||||
publish({ selection: undefined });
|
||||
}
|
||||
};
|
||||
}, [chat, emotes, frame, region, selection, surface, toWire]);
|
||||
|
||||
useBroadcast("cursor:move", (payload, from) => {
|
||||
const point = pointOf(payload, region, surface.units);
|
||||
if (point !== undefined) {
|
||||
peers.current.get(from.id)?.replay.follow(point);
|
||||
}
|
||||
});
|
||||
|
||||
const nativeCursor = useRef("");
|
||||
const wave = useCallback((on: boolean) => {
|
||||
const style = area.current!.style;
|
||||
if (on) {
|
||||
nativeCursor.current = style.cursor;
|
||||
}
|
||||
style.cursor = on ? "none" : nativeCursor.current;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!arrived || !pointing || !(chat || emotes)) {
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
void kitState("#cursors", { hinted: false }).then((seen) => {
|
||||
if (live && !seen.get().hinted) {
|
||||
seen.set({ hinted: true });
|
||||
setHinting(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [arrived, chat, emotes, pointing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hinting) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setHinting(false), HINT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hinting]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const painted = selections.flatMap(({ id, selection: theirs }) => {
|
||||
const range = rangeOf(area.current!, theirs);
|
||||
return range ? [paint(range, colorOf(id).backgroundColor)] : [];
|
||||
});
|
||||
return () => painted.forEach((unpaint) => unpaint());
|
||||
}, [selections, selection]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none invisible absolute inset-0 z-50 select-none text-[16px]"
|
||||
ref={overlay}
|
||||
>
|
||||
{placed &&
|
||||
others.map((other) => (
|
||||
<CursorLayer
|
||||
collaborator={other}
|
||||
key={`${surface.units}:${other.user.id}`}
|
||||
peers={peers.current}
|
||||
region={region}
|
||||
render={render}
|
||||
space={surface}
|
||||
/>
|
||||
))}
|
||||
{emotes && (
|
||||
<Reactions
|
||||
legend={legend}
|
||||
onLegend={showLegend}
|
||||
onWave={wave}
|
||||
peers={peers.current}
|
||||
pointer={mine}
|
||||
region={region}
|
||||
space={surface}
|
||||
toWire={toWire}
|
||||
/>
|
||||
)}
|
||||
{chat && (
|
||||
<Chat
|
||||
at={mine}
|
||||
onClose={() => setMode(undefined)}
|
||||
open={mode === "chat"}
|
||||
/>
|
||||
)}
|
||||
<Hint
|
||||
at={hinting && mode === undefined ? mine : undefined}
|
||||
chat={chat}
|
||||
emotes={emotes}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One collaborator's cursor: its replay places and fades the layer, it pops in on arrival and out on leaving, and it stands in `peers` for the reactions. */
|
||||
const CursorLayer = memo(function CursorLayer({
|
||||
collaborator: { user, presence },
|
||||
peers,
|
||||
region,
|
||||
render,
|
||||
space,
|
||||
}: {
|
||||
collaborator: Collaborator;
|
||||
peers: Map<string, Peer>;
|
||||
region: string | undefined;
|
||||
render: CursorsProps["render"];
|
||||
space: Space;
|
||||
}): ReactElement {
|
||||
const layer = useRef<HTMLDivElement>(null);
|
||||
const puppet = useRef<Puppet>(null);
|
||||
const [peer] = useState((): Peer => {
|
||||
const peer: Peer = {
|
||||
at: undefined,
|
||||
puppet: null,
|
||||
replay: new Replay(space, (at) => {
|
||||
const style = layer.current!.style;
|
||||
peer.at = at;
|
||||
if (at) {
|
||||
style.transform = `translate(${at.x}px, ${at.y}px)`;
|
||||
style.opacity = "1";
|
||||
puppet.current?.track(at.x, performance.now());
|
||||
} else {
|
||||
style.opacity = "0";
|
||||
}
|
||||
}),
|
||||
};
|
||||
return peer;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
peer.puppet = puppet.current;
|
||||
peers.set(user.id, peer);
|
||||
return () => {
|
||||
peers.delete(user.id);
|
||||
peer.replay.stop();
|
||||
popOut(layer.current!);
|
||||
};
|
||||
}, [peer, peers, user.id]);
|
||||
useLayoutEffect(
|
||||
() => peer.replay.follow(cursorOf(presence, region, space.units)!),
|
||||
[peer, presence, region, space],
|
||||
);
|
||||
|
||||
const drawn = (
|
||||
<CursorGlyph
|
||||
colors={colorOf(user.id)}
|
||||
message={messageOf(presence)}
|
||||
ref={puppet}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Layer ref={layer}>
|
||||
{render
|
||||
? render({
|
||||
user,
|
||||
color: colorOf(user.id).backgroundColor,
|
||||
presence,
|
||||
arrow: drawn,
|
||||
})
|
||||
: drawn}
|
||||
</Layer>
|
||||
);
|
||||
});
|
||||
|
||||
/** Leaves a copy of the layer behind to pop out in place. */
|
||||
function popOut(layer: HTMLElement): void {
|
||||
const ghost = layer.cloneNode(true) as HTMLElement;
|
||||
ghost.setAttribute("aria-hidden", "true");
|
||||
const content = (ghost.firstElementChild as HTMLElement | null) ?? ghost;
|
||||
content.className = cx(
|
||||
content.className,
|
||||
POP_OUT,
|
||||
"origin-top-left zoom-out-50 [&_*]:animate-none",
|
||||
);
|
||||
layer.after(ghost);
|
||||
setTimeout(() => ghost.remove(), LINGER_MS);
|
||||
}
|
||||
|
||||
/** My cursor chat: an input at my pointer while `open`, each keystroke my `message`, cleared when the input closes or after 5 s without typing. */
|
||||
function Chat({
|
||||
at,
|
||||
onClose,
|
||||
open,
|
||||
}: {
|
||||
at: Point | undefined;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
}): ReactElement | null {
|
||||
const input = useRef<HTMLInputElement | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
const type = (next: string) => {
|
||||
setText(next);
|
||||
publish({ message: next || undefined });
|
||||
};
|
||||
const attach = useCallback(
|
||||
(element: HTMLInputElement | null) => {
|
||||
input.current = element;
|
||||
if (element && open) {
|
||||
element.focus();
|
||||
}
|
||||
},
|
||||
[open],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setText("");
|
||||
} else {
|
||||
publish({ message: undefined });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !text) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => type(""), CHAT_IDLE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, text]);
|
||||
|
||||
const close = () => {
|
||||
onClose();
|
||||
input.current?.blur();
|
||||
};
|
||||
return (
|
||||
<AtPointer at={open ? at : undefined}>
|
||||
{(leaving) => (
|
||||
<label
|
||||
className={cx(
|
||||
BESIDE_ARROW,
|
||||
"block origin-[-1em_-1.5em] cursor-text",
|
||||
leaving
|
||||
? cx(POP_OUT, "zoom-out-95")
|
||||
: cx(POP_IN, "pointer-events-auto zoom-in-95"),
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
BUBBLE,
|
||||
"invisible block overflow-hidden whitespace-pre",
|
||||
)}
|
||||
>
|
||||
{text || CHAT_PLACEHOLDER}
|
||||
</span>
|
||||
<input
|
||||
aria-label="Cursor chat"
|
||||
className={cx(
|
||||
BUBBLE,
|
||||
"absolute inset-0 w-full placeholder:text-current placeholder:opacity-60 selection:bg-white/30",
|
||||
)}
|
||||
onBlur={close}
|
||||
onChange={(event) => type(event.target.value)}
|
||||
onKeyDown={(event) => event.key === "Escape" && close()}
|
||||
placeholder={CHAT_PLACEHOLDER}
|
||||
readOnly={leaving}
|
||||
ref={attach}
|
||||
style={{ ...colorOf(shared.me.user.id), outline: "none" }}
|
||||
value={text}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</AtPointer>
|
||||
);
|
||||
}
|
||||
|
||||
/** The one-time chip under my pointer naming the keys that are on. */
|
||||
function Hint({
|
||||
at,
|
||||
chat,
|
||||
emotes,
|
||||
}: {
|
||||
at: Point | undefined;
|
||||
chat: boolean;
|
||||
emotes: boolean;
|
||||
}): ReactElement | null {
|
||||
return (
|
||||
<AtPointer at={at}>
|
||||
{(leaving) => (
|
||||
<div
|
||||
className={cx(
|
||||
BESIDE_ARROW,
|
||||
PILL,
|
||||
"origin-[-1em_-1.5em] whitespace-nowrap px-[0.7em] py-[0.35em]",
|
||||
leaving
|
||||
? cx(POP_OUT, "zoom-out-95")
|
||||
: cx(POP_IN, "slide-in-from-top-1"),
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
{chat && (
|
||||
<>
|
||||
<kbd className={KEY}>/</kbd> chat
|
||||
</>
|
||||
)}
|
||||
{chat && emotes && " · "}
|
||||
{emotes && (
|
||||
<>
|
||||
<kbd className={KEY}>1</kbd>–<kbd className={KEY}>6</kbd> react
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AtPointer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everyone whose cursor is in `region` and `units`; no region matches cursors written without one. */
|
||||
function inRegion(region: string | undefined, units: Units) {
|
||||
return (all: readonly Collaborator[]): readonly Collaborator[] =>
|
||||
all.filter(
|
||||
(other) => cursorOf(other.presence, region, units) !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Each selection in `region` with whose it is. */
|
||||
function selectionsIn(region: string | undefined) {
|
||||
return (all: readonly Collaborator[]) =>
|
||||
all.flatMap(({ user, presence }) => {
|
||||
const selection = selectionOf(presence, region);
|
||||
return selection ? [{ id: user.id, selection }] : [];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { Point, Space } from "./motion";
|
||||
import type { CursorPoint, Units } from "./wire";
|
||||
import { fraction, MAX_STEPS, TOKEN } from "./wire";
|
||||
|
||||
/** How far past a child's own box the pointer may be and still anchor to it, in px at least. */
|
||||
const BESIDE_PX = 48;
|
||||
|
||||
/** The element under the pointer as an anchor, or undefined when nothing inside `area` is under it or its path is too deep to send. */
|
||||
export function anchorAt(
|
||||
area: HTMLElement,
|
||||
overlay: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): Pick<CursorPoint, "anchor" | "dx" | "dy"> | undefined {
|
||||
const hit = document
|
||||
.elementsFromPoint(clientX, clientY)
|
||||
.find((node) => area.contains(node) && !overlay.contains(node));
|
||||
if (!hit) {
|
||||
return undefined;
|
||||
}
|
||||
const element = leafNear(blockOf(hit, area), overlay, clientX, clientY);
|
||||
const box = element.getBoundingClientRect();
|
||||
const anchor = pathTo(area, element);
|
||||
if (box.width === 0 || box.height === 0 || anchor.length > MAX_STEPS) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
anchor,
|
||||
dx: fraction((clientX - box.left) / box.width),
|
||||
dy: fraction((clientY - box.top) / box.height),
|
||||
};
|
||||
}
|
||||
|
||||
/** The element `anchor` names inside `area`, when it is there. */
|
||||
export function elementAt(
|
||||
area: HTMLElement,
|
||||
anchor: readonly string[],
|
||||
): Element | undefined {
|
||||
const [root, ...steps] = anchor;
|
||||
let node: Element | null =
|
||||
root === "" ? area : area.querySelector(`[id="${root!.slice(1)}"]`);
|
||||
for (const step of steps) {
|
||||
const at = step.lastIndexOf(":");
|
||||
node =
|
||||
node?.querySelector(
|
||||
`:scope > ${step.slice(0, at)}:nth-of-type(${Number(step.slice(at + 1))})`,
|
||||
) ?? null;
|
||||
}
|
||||
return node ?? undefined;
|
||||
}
|
||||
|
||||
/** The page a `<Cursors>` overlay is drawn on: a wire point as px of the overlay as it lays out now, a missing or collapsed anchor left at its fractions. */
|
||||
export class DomSpace implements Space {
|
||||
readonly units: Units = undefined;
|
||||
|
||||
constructor(private readonly overlay: () => HTMLElement) {}
|
||||
|
||||
resolve(point: CursorPoint): Point {
|
||||
const overlay = this.overlay().getBoundingClientRect();
|
||||
const box =
|
||||
point.anchor &&
|
||||
elementAt(areaOf(this.overlay()), point.anchor)?.getBoundingClientRect();
|
||||
if (!box || box.width === 0 || box.height === 0) {
|
||||
return { x: point.x * overlay.width, y: point.y * overlay.height };
|
||||
}
|
||||
return {
|
||||
x: box.left + point.dx! * box.width - overlay.left,
|
||||
y: box.top + point.dy! * box.height - overlay.top,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The element the overlay covers: its positioned parent, or the page when there is none. */
|
||||
export function areaOf(overlay: HTMLElement): HTMLElement {
|
||||
const positioned = overlay.offsetParent as HTMLElement | null;
|
||||
return !positioned || positioned === document.body
|
||||
? document.documentElement
|
||||
: positioned;
|
||||
}
|
||||
|
||||
/** Whether the element lays out as a box of its own. */
|
||||
function isBlock(element: Element): boolean {
|
||||
const display = getComputedStyle(element).display;
|
||||
return display !== "inline" && display !== "contents";
|
||||
}
|
||||
|
||||
/** `element`, or the nearest block ancestor inside `area`. */
|
||||
function blockOf(element: Element, area: HTMLElement): Element {
|
||||
let node = element;
|
||||
while (node !== area && !isBlock(node) && node.parentElement) {
|
||||
node = node.parentElement;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The element to anchor to: `element` itself when it has no laid-out block children, else the nearest such child when the pointer is inside or just beside it. */
|
||||
function leafNear(
|
||||
element: Element,
|
||||
overlay: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): Element {
|
||||
for (;;) {
|
||||
let near: Element | undefined;
|
||||
let nearBox: DOMRect | undefined;
|
||||
let least = Infinity;
|
||||
for (const child of element.children) {
|
||||
if (child === overlay || !isBlock(child)) {
|
||||
continue;
|
||||
}
|
||||
const box = child.getBoundingClientRect();
|
||||
if (box.width === 0 || box.height === 0) {
|
||||
continue;
|
||||
}
|
||||
const distance = Math.hypot(
|
||||
Math.max(box.left - clientX, 0, clientX - box.right),
|
||||
Math.max(box.top - clientY, 0, clientY - box.bottom),
|
||||
);
|
||||
if (distance < least) {
|
||||
least = distance;
|
||||
near = child;
|
||||
nearBox = box;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!near ||
|
||||
least > Math.max(BESIDE_PX, Math.min(nearBox!.width, nearBox!.height))
|
||||
) {
|
||||
return element;
|
||||
}
|
||||
if (least > 0) {
|
||||
return near;
|
||||
}
|
||||
element = near;
|
||||
}
|
||||
}
|
||||
|
||||
function pathTo(area: HTMLElement, element: Element): string[] {
|
||||
const path: string[] = [];
|
||||
for (let node = element; node !== area; node = node.parentElement!) {
|
||||
if (TOKEN.test(node.id)) {
|
||||
path.unshift(`#${node.id}`);
|
||||
return path;
|
||||
}
|
||||
const siblings = node.parentElement!.querySelectorAll(
|
||||
`:scope > ${node.localName}`,
|
||||
);
|
||||
path.unshift(
|
||||
`${node.localName}:${Array.prototype.indexOf.call(siblings, node) + 1}`,
|
||||
);
|
||||
}
|
||||
path.unshift("");
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Point, Space } from "./motion";
|
||||
import type { CursorPoint, Units } from "./wire";
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
export class CameraSpace implements Space {
|
||||
readonly units: Units = "page";
|
||||
readonly camera = true;
|
||||
|
||||
constructor(
|
||||
private readonly overlay: () => HTMLElement,
|
||||
private readonly space: () => CursorSpace,
|
||||
) {}
|
||||
|
||||
resolve(point: CursorPoint): Point {
|
||||
const rect = this.overlay().getBoundingClientRect();
|
||||
const at = this.space().toClient(point.x, point.y);
|
||||
return { x: at.x - rect.left, y: at.y - rect.top };
|
||||
}
|
||||
}
|
||||
|
||||
export function finite(at: Point): Point | undefined {
|
||||
return Number.isFinite(at.x) && Number.isFinite(at.y) ? at : undefined;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { CSSProperties, ReactElement } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Ease } from "./tokens";
|
||||
import {
|
||||
BESIDE_ARROW,
|
||||
BOUNCE,
|
||||
cx,
|
||||
EMOTE_MS,
|
||||
KEY,
|
||||
PILL,
|
||||
POP_OUT,
|
||||
reduced,
|
||||
SETTLE,
|
||||
springAt,
|
||||
} from "./tokens";
|
||||
|
||||
export const EMOJI = ["👍", "❤️", "🎉", "😂", "👀", "🙌"];
|
||||
|
||||
/** How far an emote rises, in em of the cursor, and the arcs a burst cycles through, in em to the side. */
|
||||
const RISE_EM = 9;
|
||||
const ARCS = [1.2, -2.2, 2.8, -1.2, 2.2, -2.8];
|
||||
/** Emotes closer together than this are one burst. */
|
||||
const BURST_GAP_MS = 1_500;
|
||||
|
||||
/** Hands out the arc of each emote I send: the next of a burst turns the other way. */
|
||||
export class Stream {
|
||||
private t = 0;
|
||||
private index = 0;
|
||||
|
||||
next(now: number): number {
|
||||
this.index = now - this.t < BURST_GAP_MS ? this.index + 1 : 0;
|
||||
this.t = now;
|
||||
return this.index % ARCS.length;
|
||||
}
|
||||
}
|
||||
|
||||
/** The legend of emote keys beside the tip, the six emoji over the digit that sends each; a picture only, the pointer goes through it. */
|
||||
export function Legend({ leaving }: { leaving: boolean }): ReactElement {
|
||||
return (
|
||||
<ul
|
||||
aria-label="Emote keys"
|
||||
className={cx(
|
||||
BESIDE_ARROW,
|
||||
PILL,
|
||||
"flex origin-[-1em_-1.5em] gap-[0.1em] p-[0.25em]",
|
||||
leaving
|
||||
? cx(POP_OUT, "zoom-out-95")
|
||||
: "animate-in fade-in zoom-in-90 fill-mode-both motion-reduce:animate-none",
|
||||
)}
|
||||
role="list"
|
||||
style={leaving ? undefined : timing(BOUNCE)}
|
||||
>
|
||||
{EMOJI.map((emoji, index) => (
|
||||
<li
|
||||
className="flex flex-col items-center px-[0.4em] pb-[0.25em] pt-[0.3em] animate-in fade-in slide-in-from-bottom-1 fill-mode-backwards motion-reduce:animate-none"
|
||||
key={emoji}
|
||||
style={timing(SETTLE, 40 + index * 18)}
|
||||
>
|
||||
<span className="text-[1.35em] leading-none">{emoji}</span>
|
||||
<kbd className={cx(KEY, "mt-[0.3em] text-[0.65em] leading-tight")}>
|
||||
{index + 1}
|
||||
</kbd>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** One emoji born centred on the parent's origin, rising on arc `turn` and fading; `onEnd` when it is gone. */
|
||||
export function Emoji({
|
||||
emoji,
|
||||
onEnd,
|
||||
turn,
|
||||
}: {
|
||||
emoji: string;
|
||||
onEnd: () => void;
|
||||
turn: number;
|
||||
}): ReactElement {
|
||||
const element = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
const flight = element.current!.animate(
|
||||
reduced() ? RESTING : arc(ARCS[turn]!),
|
||||
{ duration: EMOTE_MS, easing: "linear", fill: "both" },
|
||||
);
|
||||
const timer = setTimeout(onEnd, EMOTE_MS);
|
||||
return () => {
|
||||
flight.cancel();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [onEnd, turn]);
|
||||
return (
|
||||
<span
|
||||
className="absolute left-0 top-0 block opacity-0 will-change-transform"
|
||||
data-emote={emoji}
|
||||
ref={element}
|
||||
>
|
||||
<span className="block text-[2em] leading-none">{emoji}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The flight, sampled: a spring-in, an arc of `side` em to the side while rising, then a fade and a blur. */
|
||||
function arc(side: number): Keyframe[] {
|
||||
const frames: Keyframe[] = [];
|
||||
const samples = 28;
|
||||
for (let index = 0; index <= samples; index += 1) {
|
||||
const u = index / samples;
|
||||
const rise = RISE_EM * (1 - (1 - u) ** 3);
|
||||
const x = side * Math.sin(Math.PI * u);
|
||||
const slope = side * Math.PI * Math.cos(Math.PI * u);
|
||||
const climb = RISE_EM * 3 * (1 - u) ** 2 + 0.6;
|
||||
const tilt = Math.max(
|
||||
-14,
|
||||
Math.min(14, ((Math.atan2(slope, climb) * 180) / Math.PI) * 0.5),
|
||||
);
|
||||
const scale = u < 0.25 ? 0.3 + 0.7 * springAt(0.45, u / 0.25) : 1;
|
||||
const fading = Math.max(0, (u - 0.55) / 0.45);
|
||||
const blur = Math.max(0, (u - 0.7) / 0.3) * 1.5;
|
||||
frames.push({
|
||||
transform: `translate(-50%, -50%) translate(${x.toFixed(3)}em, ${(-rise).toFixed(3)}em) rotate(${tilt.toFixed(1)}deg) scale(${scale.toFixed(3)})`,
|
||||
opacity: 1 - fading * fading,
|
||||
filter: `blur(${blur.toFixed(2)}px)`,
|
||||
offset: u,
|
||||
});
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** The reduced-motion flight: a fade in, a hold and a fade out in place. */
|
||||
const RESTING: Keyframe[] = [0, 1, 1, 0].map((opacity, index) => ({
|
||||
transform: "translate(-50%, -50%) translate(0, -4em)",
|
||||
opacity,
|
||||
offset: [0, 0.1, 0.7, 1][index],
|
||||
}));
|
||||
|
||||
/** How an `animate-in` class runs: `ease`'s duration and spring, after `delay` ms. */
|
||||
function timing(ease: Ease, delay = 0): CSSProperties {
|
||||
return {
|
||||
animationDelay: `${delay}ms`,
|
||||
animationDuration: `${ease.duration}ms`,
|
||||
animationTimingFunction: ease.easing,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { Point } from "./motion";
|
||||
|
||||
/** A pointer position in px with the time it was seen, in ms. */
|
||||
interface Sample {
|
||||
x: number;
|
||||
y: number;
|
||||
t: number;
|
||||
}
|
||||
|
||||
const WAVE_WINDOW_MS = 1_000;
|
||||
/** How far the pointer must come back from a leg's far end for the leg to count as a turn. */
|
||||
const WAVE_LEG_PX = 10;
|
||||
/** Two full cycles. */
|
||||
const WAVE_TURNS = 4;
|
||||
/** The most the pointer may wander vertically, as a share of its horizontal range. */
|
||||
const WAVE_VERTICAL_SHARE = 0.5;
|
||||
|
||||
/**
|
||||
* Tells a wave from ordinary pointer movement: four horizontal turns of at
|
||||
* least 10 px inside 1 s, staying within half that range vertically. Once
|
||||
* waving, every further turn counts until `end()`.
|
||||
*/
|
||||
export class Wiggle {
|
||||
waving = false;
|
||||
private samples: Sample[] = [];
|
||||
|
||||
/** Adds a pointer sample; true when it starts a wave or, while waving, is another turn. */
|
||||
push(x: number, y: number, t: number): boolean {
|
||||
this.samples.push({ x, y, t });
|
||||
while (this.samples[0]!.t < t - WAVE_WINDOW_MS) {
|
||||
this.samples.shift();
|
||||
}
|
||||
const { turns, turned, flat } = scan(this.samples);
|
||||
if (this.waving) {
|
||||
return turned;
|
||||
}
|
||||
if (turns >= WAVE_TURNS && flat) {
|
||||
this.waving = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.waving = false;
|
||||
this.samples = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** How many times the pointer turned, whether the last sample was a turn, and whether it stayed flat from the first turn on. */
|
||||
function scan(samples: Sample[]): {
|
||||
turns: number;
|
||||
turned: boolean;
|
||||
flat: boolean;
|
||||
} {
|
||||
let turns = 0;
|
||||
let turned = false;
|
||||
let direction = 0;
|
||||
let far = samples[0]!.x;
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const { x, y } of samples) {
|
||||
turned = false;
|
||||
const ahead = (x - far) * direction;
|
||||
if (direction === 0) {
|
||||
if (Math.abs(x - far) >= WAVE_LEG_PX) {
|
||||
direction = Math.sign(x - far);
|
||||
far = x;
|
||||
}
|
||||
} else if (ahead > 0) {
|
||||
far = x;
|
||||
} else if (-ahead >= WAVE_LEG_PX) {
|
||||
turns += 1;
|
||||
turned = true;
|
||||
direction = -direction;
|
||||
if (turns === 1) {
|
||||
minX = maxX = far;
|
||||
minY = maxY = y;
|
||||
}
|
||||
far = x;
|
||||
}
|
||||
if (turns > 0) {
|
||||
minX = Math.min(minX, x);
|
||||
maxX = Math.max(maxX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
return {
|
||||
turns,
|
||||
turned,
|
||||
flat: maxY - minY <= (maxX - minX) * WAVE_VERTICAL_SHARE,
|
||||
};
|
||||
}
|
||||
|
||||
const NEAR_PX = 24;
|
||||
/** How far a pair may drift apart before the offer is withdrawn. */
|
||||
const APART_PX = 36;
|
||||
const DWELL_MS = 300;
|
||||
const OFFER_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Measures my pointer against everyone's drawn cursor every frame and names
|
||||
* a `partner` once one has stayed within 24 px for 300 ms. The offer stands
|
||||
* 2 s, and a pair that let it pass, or took it, is not offered again until
|
||||
* they part.
|
||||
*/
|
||||
export class Meeting {
|
||||
partner: string | undefined;
|
||||
private me: Point | undefined;
|
||||
private readonly spent = new Set<string>();
|
||||
private candidate: string | undefined;
|
||||
private dwell: ReturnType<typeof setTimeout> | undefined;
|
||||
private offer: ReturnType<typeof setTimeout> | undefined;
|
||||
private frame = 0;
|
||||
|
||||
constructor(
|
||||
private readonly peers: ReadonlyMap<string, { at: Point | undefined }>,
|
||||
private readonly onChange: (partner: string | undefined) => void,
|
||||
) {}
|
||||
|
||||
/** My pointer, or undefined once it left. */
|
||||
mine(at: Point | undefined): void {
|
||||
this.me = at;
|
||||
this.measure();
|
||||
cancelAnimationFrame(this.frame);
|
||||
this.frame = at ? requestAnimationFrame(this.tick) : 0;
|
||||
}
|
||||
|
||||
/** Which side of the partner my pointer is on: -1 left, 1 right. */
|
||||
side(): 1 | -1 {
|
||||
const theirs = this.theirs();
|
||||
return this.me && theirs && this.me.x > theirs.x ? 1 : -1;
|
||||
}
|
||||
|
||||
/** The point between my pointer and the partner's cursor. */
|
||||
point(): Point | undefined {
|
||||
const theirs = this.theirs();
|
||||
return this.me && theirs
|
||||
? { x: (this.me.x + theirs.x) / 2, y: (this.me.y + theirs.y) / 2 }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Closes the offer with `id`: the high five happened. */
|
||||
done(id: string): void {
|
||||
this.spent.add(id);
|
||||
if (this.candidate === id) {
|
||||
this.settle(undefined);
|
||||
}
|
||||
if (this.partner === id) {
|
||||
this.set(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
clearTimeout(this.dwell);
|
||||
clearTimeout(this.offer);
|
||||
cancelAnimationFrame(this.frame);
|
||||
}
|
||||
|
||||
private readonly tick = (): void => {
|
||||
this.measure();
|
||||
this.frame = requestAnimationFrame(this.tick);
|
||||
};
|
||||
|
||||
private theirs(): Point | undefined {
|
||||
return this.partner === undefined
|
||||
? undefined
|
||||
: this.peers.get(this.partner)?.at;
|
||||
}
|
||||
|
||||
private measure(): void {
|
||||
const near = this.nearest(NEAR_PX);
|
||||
for (const id of this.spent) {
|
||||
if (id !== near) {
|
||||
this.spent.delete(id);
|
||||
}
|
||||
}
|
||||
if (this.partner !== undefined && this.nearest(APART_PX) !== this.partner) {
|
||||
this.set(undefined);
|
||||
}
|
||||
if (near !== this.candidate) {
|
||||
this.settle(near);
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the dwell on `id`, replacing whichever was running. */
|
||||
private settle(id: string | undefined): void {
|
||||
clearTimeout(this.dwell);
|
||||
this.candidate = id;
|
||||
if (id === undefined || this.spent.has(id) || id === this.partner) {
|
||||
return;
|
||||
}
|
||||
this.dwell = setTimeout(() => this.set(id), DWELL_MS);
|
||||
}
|
||||
|
||||
private set(partner: string | undefined): void {
|
||||
clearTimeout(this.offer);
|
||||
if (partner !== undefined) {
|
||||
this.offer = setTimeout(() => {
|
||||
this.spent.add(partner);
|
||||
this.set(undefined);
|
||||
}, OFFER_MS);
|
||||
}
|
||||
if (this.partner !== partner) {
|
||||
this.partner = partner;
|
||||
this.onChange(partner);
|
||||
}
|
||||
}
|
||||
|
||||
private nearest(within: number): string | undefined {
|
||||
if (!this.me) {
|
||||
return undefined;
|
||||
}
|
||||
const me = this.me;
|
||||
let best: string | undefined;
|
||||
let least = within;
|
||||
for (const [id, { at }] of this.peers) {
|
||||
const distance = at ? Math.hypot(at.x - me.x, at.y - me.y) : Infinity;
|
||||
if (distance <= least) {
|
||||
best = id;
|
||||
least = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
||||
import { forwardRef, useRef } from "react";
|
||||
import type { User } from "../user";
|
||||
import type { Puppet } from "./hand";
|
||||
import { HandSlot } from "./hand";
|
||||
import type { Point } from "./motion";
|
||||
import { BESIDE_ARROW, BUBBLE, cx, POP_IN, POP_OUT, useLinger } from "./tokens";
|
||||
|
||||
/** The arrow's outline, its tip at (17, 6.2); the view box starts there so the tip is the top-left corner. */
|
||||
const ARROW =
|
||||
"M17 6.2 65.1 34.8C66.3 35.5 66.1 36.6 65 37.1L43 45.5C42 45.9 41.3 46.6 40.7 47.4L26.5 66.1C25.8 67.2 24.6 66.8 24.3 65.7L13.3 10.5C12.9 8.8 13.4 7.3 14.9 6.6C15.5 6.3 16.2 6.1 17 6.2Z";
|
||||
|
||||
/** 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. */
|
||||
export const CursorGlyph = forwardRef<
|
||||
Puppet,
|
||||
{
|
||||
colors: { backgroundColor: string; color: string };
|
||||
message: string | undefined;
|
||||
user: User;
|
||||
}
|
||||
>(function CursorGlyph({ colors, message, user }, ref): ReactElement {
|
||||
const arrow = useRef<HTMLDivElement>(null);
|
||||
const shown = useLinger(message);
|
||||
return (
|
||||
<div
|
||||
aria-label={user.name}
|
||||
className={cx(POP_IN, "relative inline-flex origin-top-left zoom-in-50")}
|
||||
role="img"
|
||||
>
|
||||
<div
|
||||
className="origin-top-left transition-[filter] duration-300 motion-reduce:transition-none data-[near]:drop-shadow-[0_0_0.4em_currentColor]"
|
||||
ref={arrow}
|
||||
style={{ color: colors.backgroundColor }}
|
||||
>
|
||||
<CursorArrow className="h-[1.25em] drop-shadow" />
|
||||
</div>
|
||||
<div className={cx(BESIDE_ARROW, BUBBLE, "flex flex-col")} style={colors}>
|
||||
<span
|
||||
className={cx(
|
||||
"whitespace-nowrap text-[0.9em] font-medium leading-tight transition-opacity duration-150 motion-reduce:transition-none",
|
||||
shown.value !== undefined && "opacity-80",
|
||||
)}
|
||||
>
|
||||
{user.name}
|
||||
</span>
|
||||
{shown.value !== undefined && (
|
||||
<span
|
||||
className={cx(
|
||||
"break-words",
|
||||
shown.leaving
|
||||
? cx(POP_OUT, "zoom-out-95")
|
||||
: cx(POP_IN, "slide-in-from-top-1"),
|
||||
)}
|
||||
>
|
||||
{shown.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<HandSlot arrow={arrow} color={colors.backgroundColor} ref={ref} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 function CursorArrow({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
overflow="visible"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
style={style}
|
||||
viewBox="17 6 50 61"
|
||||
>
|
||||
<path
|
||||
d={ARROW}
|
||||
fill="currentColor"
|
||||
stroke="white"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export const Layer = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ at?: Point; children: ReactNode }
|
||||
>(function Layer({ at, children }, ref): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-300 will-change-transform motion-reduce:transition-none"
|
||||
ref={ref}
|
||||
style={at && { transform: `translate(${at.x}px, ${at.y}px)` }}
|
||||
>
|
||||
<div className="visible w-max">{children}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** 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. */
|
||||
export function AtPointer({
|
||||
at,
|
||||
children,
|
||||
}: {
|
||||
at: Point | undefined;
|
||||
children: (leaving: boolean) => ReactNode;
|
||||
}): ReactElement | null {
|
||||
const shown = useLinger(at);
|
||||
if (shown.value === undefined) {
|
||||
return null;
|
||||
}
|
||||
return <Layer at={shown.value}>{children(shown.leaving)}</Layer>;
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import type { ReactElement, RefObject } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { Point } from "./motion";
|
||||
import type { Ease } from "./tokens";
|
||||
import {
|
||||
ANTICIPATE_MS,
|
||||
BOUNCE,
|
||||
CLAP_MS,
|
||||
FADE,
|
||||
play,
|
||||
reduced,
|
||||
SNAP,
|
||||
spring,
|
||||
} from "./tokens";
|
||||
|
||||
/** Where each hand's wrist sits at contact, either side of the meeting point, and where it recoils to, in px. */
|
||||
const TOUCH_PX = 18;
|
||||
const RECOIL_PX = 30;
|
||||
/** Microsoft's Fluent Emoji Flat "Raised hand" (MIT), mirrored and fitted to a 100 × 120 box with the wrist at the bottom centre. */
|
||||
const HAND =
|
||||
"M80.1 25.9C74.7 25.9 73.1 28.9 73.1 28.9L72.7 21.3C72.4 17.3 67.8 13.4 63.4 13.4C57.5 13.4 55.2 17.7 55.2 17.7C55.6 8.6 52.3 4 44.1 4C37.5 4.4 34.8 10.7 35.3 17.7C35.3 17.7 32.5 12.2 24.8 12.9C20.5 13.3 17 17.7 17 25L17.7 56.8C17.7 56.8 12.8 49.7 9.1 47.6C5.5 45.5 -0.6 45.6 -3.6 48.8C-7.1 52.5 -7.8 58.4 -5.2 62C4.2 74.2 13.3 87.8 16.1 93.4C23.5 108.4 34.8 120 50 120C68.4 120 85.4 106.5 87.4 81.9C89.4 57.3 89.7 37.2 89.7 37.2C90 32.9 86.6 25.9 80.1 25.9Z";
|
||||
/** Fluent's three finger creases. */
|
||||
const CREASES =
|
||||
"M51.4 14.4L49.2 46.6C49.1 47.7 50 48.7 51.1 48.8C52.3 48.8 53.2 48 53.3 46.8L55.3 17.5C55.2 17.6 55.2 17.7 55.2 17.7C55.6 9.1 52.7 4.5 45.5 4C47.4 4.1 48.9 5 49.9 6.7C51 8.5 51.6 11.2 51.4 14.4ZM35.3 17.7C35.3 17.3 35.2 17 35.2 16.6C35.9 18.6 36.2 20.8 36.3 23L36.8 48.5C36.9 49.6 35.9 50.6 34.8 50.6C33.7 50.6 32.7 49.7 32.7 48.6L32.2 23.2C32.1 20 31.3 17.3 30.1 15.5C29.2 14 27.9 13.1 26.3 12.9C32.9 13 35.3 17.7 35.3 17.7ZM73.1 28.8L72.7 21.3C72.4 17.4 67.9 13.5 63.5 13.4C65.4 13.7 66.7 14.2 67.5 14.9C68.3 15.5 68.9 16.2 69.2 17.2C70 19.1 69.8 21.8 69.5 25L65.9 51.6C65.7 52.8 66.5 53.8 67.7 54C68.8 54.1 69.8 53.3 70 52.2L73.1 28.8Z";
|
||||
/** A darker shade of the hand's own colour, for its outline and creases. */
|
||||
const OUTLINE = "color-mix(in oklab, currentColor 72%, black)";
|
||||
|
||||
/** 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. */
|
||||
export const HandSlot = forwardRef<
|
||||
Puppet,
|
||||
{ arrow?: RefObject<Element>; color: string }
|
||||
>(function HandSlot({ arrow = { current: null }, color }, ref): ReactElement {
|
||||
const slot = useRef<HTMLDivElement>(null);
|
||||
const svg = useRef<SVGSVGElement>(null);
|
||||
const [puppet] = useState(() => new Puppet(slot, svg, arrow));
|
||||
useImperativeHandle(ref, () => puppet, [puppet]);
|
||||
useEffect(() => () => puppet.stop(), [puppet]);
|
||||
return (
|
||||
<div
|
||||
className="absolute left-[-1.25em] top-[-3em] h-[3em] w-[2.5em] origin-bottom opacity-0 will-change-transform"
|
||||
data-hand=""
|
||||
ref={slot}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-full w-full origin-bottom drop-shadow"
|
||||
overflow="visible"
|
||||
ref={svg}
|
||||
style={{ color }}
|
||||
viewBox="0 0 100 120"
|
||||
>
|
||||
<path
|
||||
d={HAND}
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="13"
|
||||
/>
|
||||
<path
|
||||
d={HAND}
|
||||
fill="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
style={{ stroke: OUTLINE }}
|
||||
/>
|
||||
<path
|
||||
d={CREASES}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
style={{ fill: OUTLINE, stroke: OUTLINE }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** The hand's pose from the pointer's horizontal velocity: it swings with the motion, leans into it and squashes on each turn. */
|
||||
export class Swing {
|
||||
private angle = 0;
|
||||
private velocity = 0;
|
||||
private lastX: number | undefined;
|
||||
private lastT = 0;
|
||||
|
||||
/** A position sample in px at time `t` in ms. */
|
||||
push(x: number, t: number): void {
|
||||
if (this.lastX !== undefined && t > this.lastT) {
|
||||
const velocity = ((x - this.lastX) / (t - this.lastT)) * 1000;
|
||||
this.velocity = this.velocity * 0.4 + velocity * 0.6;
|
||||
}
|
||||
this.lastX = x;
|
||||
this.lastT = t;
|
||||
}
|
||||
|
||||
/** The transform for this frame; the velocity decays while no sample lands. */
|
||||
pose(now: number): string {
|
||||
if (now - this.lastT > 48) {
|
||||
this.velocity *= 0.8;
|
||||
}
|
||||
const target = Math.max(-32, Math.min(32, this.velocity * 0.035));
|
||||
const previous = this.angle;
|
||||
this.angle += (target - this.angle) * 0.28;
|
||||
const squash = 1 - Math.min(0.18, Math.abs(this.angle - previous) / 240);
|
||||
return `rotate(${this.angle.toFixed(2)}deg) skewX(${(-this.angle * 0.2).toFixed(2)}deg) scale(${squash.toFixed(3)}, ${(1 / squash).toFixed(3)})`;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.angle = 0;
|
||||
this.velocity = 0;
|
||||
this.lastX = undefined;
|
||||
this.lastT = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
const HIDDEN = "scale(0.3)";
|
||||
/** A waving hand whose owner sent no motion for this long goes back to an arrow. */
|
||||
const WAVE_WATCHDOG_MS = 1_500;
|
||||
/** When the hands meet inside the clap clip. */
|
||||
export const IMPACT_MS = 440;
|
||||
|
||||
type State = "" | "arm" | "wave" | "clap";
|
||||
|
||||
/** 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. */
|
||||
export class Puppet {
|
||||
private readonly swing = new Swing();
|
||||
private readonly running = new Map<Element, Animation>();
|
||||
private state: State = "";
|
||||
/** The side a high five on offer wants the small hand on, kept through a clap. */
|
||||
private armed: 1 | -1 | undefined;
|
||||
private frame = 0;
|
||||
private bob: Animation | undefined;
|
||||
private watchdog: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly slot: RefObject<HTMLDivElement>,
|
||||
private readonly svg: RefObject<SVGSVGElement>,
|
||||
private readonly 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 = -1): void {
|
||||
this.armed = on ? side : undefined;
|
||||
const slot = this.slot.current;
|
||||
if (!slot || this.state === "clap") {
|
||||
return;
|
||||
}
|
||||
this.bob?.cancel();
|
||||
if (on) {
|
||||
this.enter(
|
||||
{
|
||||
transform: `translate(${side * 1.1}em, 0.3em) ${HIDDEN}`,
|
||||
opacity: 0,
|
||||
},
|
||||
{
|
||||
transform: `translate(${side * 1.1}em, -0.3em) scale(0.55)`,
|
||||
opacity: 0.7,
|
||||
},
|
||||
BOUNCE,
|
||||
);
|
||||
if (!reduced()) {
|
||||
this.bob = slot.animate(
|
||||
[0, -2, 0].map((y) => ({ transform: `translateY(${y}px)` })),
|
||||
{
|
||||
composite: "add",
|
||||
duration: 1_200,
|
||||
easing: "ease-in-out",
|
||||
iterations: Infinity,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.run(slot, [{ transform: HIDDEN, opacity: 0 }], FADE);
|
||||
}
|
||||
this.mark(on ? "arm" : "");
|
||||
}
|
||||
|
||||
wave(on: boolean): void {
|
||||
const slot = this.slot.current;
|
||||
const arrow = this.arrow.current;
|
||||
if (!slot || this.state === "clap") {
|
||||
return;
|
||||
}
|
||||
this.still();
|
||||
if (on) {
|
||||
this.swing.reset();
|
||||
this.watchdog = setTimeout(() => this.wave(false), WAVE_WATCHDOG_MS);
|
||||
this.enter(
|
||||
{ transform: "scale(0.4)", opacity: 0 },
|
||||
{ transform: "scale(1)", opacity: 1 },
|
||||
BOUNCE,
|
||||
);
|
||||
if (arrow) {
|
||||
this.run(arrow, [{ transform: "scale(0.7)", opacity: 0 }], {
|
||||
duration: 150,
|
||||
easing: "ease-out",
|
||||
});
|
||||
}
|
||||
if (!reduced()) {
|
||||
this.frame = requestAnimationFrame(this.draw);
|
||||
}
|
||||
} else {
|
||||
this.run(slot, [{ transform: "scale(0.6)", opacity: 0 }], FADE);
|
||||
if (arrow) {
|
||||
this.run(arrow, [{ transform: "scale(1)", opacity: 1 }], SNAP);
|
||||
}
|
||||
}
|
||||
this.mark(on ? "wave" : "");
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
this.swing.push(x, t);
|
||||
if (this.state === "wave") {
|
||||
clearTimeout(this.watchdog);
|
||||
this.watchdog = setTimeout(() => this.wave(false), WAVE_WATCHDOG_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
const slot = this.slot.current;
|
||||
const arrow = this.arrow.current;
|
||||
if (!slot) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.still();
|
||||
this.mark("clap");
|
||||
const mirror = `scaleX(${-side})`;
|
||||
const at = (back: number, scale: string) =>
|
||||
`translate(${(to.x + side * back).toFixed(1)}px, ${to.y.toFixed(1)}px) ${mirror} ${scale}`;
|
||||
const pulled = `translate(${side * 6}px, 0) ${mirror} scale(0.5)`;
|
||||
const clip = this.run(
|
||||
slot,
|
||||
beats([
|
||||
[pulled, 0, 0, "ease-out"],
|
||||
[pulled, 1, ANTICIPATE_MS, SNAP.easing],
|
||||
[at(TOUCH_PX, "scale(1)"), 1, IMPACT_MS, "ease-out"],
|
||||
[at(TOUCH_PX - 4, "scale(1.15, 0.85)"), 1, 520, spring(0.45)],
|
||||
[at(RECOIL_PX, "scale(1)"), 1, 700, "linear"],
|
||||
[at(RECOIL_PX, "scale(1)"), 1, 1_100, SNAP.easing],
|
||||
[`${mirror} scale(0.5)`, 0, CLAP_MS],
|
||||
]),
|
||||
{ duration: CLAP_MS, easing: "linear" },
|
||||
hold(`${mirror} scale(1)`, [0, 1, 1, 0]),
|
||||
);
|
||||
if (arrow) {
|
||||
const away = `translate(${side * 6}px, 0) scale(0.85)`;
|
||||
this.run(
|
||||
arrow,
|
||||
beats([
|
||||
["scale(1)", 1, 0, "ease-out"],
|
||||
[away, 1, ANTICIPATE_MS, "ease-out"],
|
||||
[away, 0, 320, "linear"],
|
||||
[away, 0, 1_100, SNAP.easing],
|
||||
["scale(1)", 1, CLAP_MS],
|
||||
]),
|
||||
{ duration: CLAP_MS, easing: "linear" },
|
||||
hold("scale(1)", [1, 0, 0, 1]),
|
||||
);
|
||||
}
|
||||
const settle = () => {
|
||||
this.mark("");
|
||||
if (this.armed !== undefined) {
|
||||
this.arm(true, this.armed);
|
||||
}
|
||||
};
|
||||
return clip.finished.then(settle, () => this.mark(""));
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.still();
|
||||
this.running.forEach((animation) => animation.cancel());
|
||||
}
|
||||
|
||||
/** Ends the bob, the swing and its watchdog. */
|
||||
private still(): void {
|
||||
this.bob?.cancel();
|
||||
cancelAnimationFrame(this.frame);
|
||||
clearTimeout(this.watchdog);
|
||||
this.svg.current?.style.removeProperty("transform");
|
||||
}
|
||||
|
||||
private readonly draw = (): void => {
|
||||
const svg = this.svg.current;
|
||||
if (svg) {
|
||||
svg.style.transform = this.swing.pose(performance.now());
|
||||
}
|
||||
this.frame = requestAnimationFrame(this.draw);
|
||||
};
|
||||
|
||||
/** Names the hand's state on its slot, and the glow on the arrow while a high five is on offer. */
|
||||
private mark(state: State): void {
|
||||
this.state = state;
|
||||
const slot = this.slot.current;
|
||||
if (slot) {
|
||||
slot.dataset.hand = state;
|
||||
}
|
||||
const arrow = this.arrow.current;
|
||||
if (arrow instanceof HTMLElement) {
|
||||
if (state === "arm") {
|
||||
arrow.dataset.near = "";
|
||||
} else {
|
||||
delete arrow.dataset.near;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Animates the slot to `to`: from `from` while the hand is hidden, else from wherever its last animation left it. */
|
||||
private enter(from: Keyframe, to: Keyframe, ease: Ease): void {
|
||||
this.run(this.slot.current!, this.state === "" ? [from, to] : [to], ease);
|
||||
}
|
||||
|
||||
/** Animates `element` through `frames`, from wherever its last animation left it. */
|
||||
private run(
|
||||
element: Element,
|
||||
frames: Keyframe[],
|
||||
ease: Ease,
|
||||
still?: Keyframe[],
|
||||
): Animation {
|
||||
const previous = this.running.get(element);
|
||||
if (previous) {
|
||||
try {
|
||||
previous.commitStyles();
|
||||
} catch {
|
||||
// A detached element has no styles to keep.
|
||||
}
|
||||
previous.cancel();
|
||||
}
|
||||
const animation = play(element, frames, ease, still);
|
||||
this.running.set(element, animation);
|
||||
return animation;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keyframes of the clap clip from its beats: a transform and opacity at a time in ms, eased to the next beat. */
|
||||
function beats(
|
||||
clip: Array<
|
||||
[transform: string, opacity: number, ms: number, easing?: string]
|
||||
>,
|
||||
): Keyframe[] {
|
||||
return clip.map(([transform, opacity, ms, easing]) => ({
|
||||
transform,
|
||||
opacity,
|
||||
offset: ms / CLAP_MS,
|
||||
...(easing === undefined ? {} : { easing }),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The reduced-motion clap: still at `transform`, at the given opacities at the start, the impact, the end of the hold and the end. */
|
||||
function hold(transform: string, opacity: [number, number, number, number]) {
|
||||
return beats(
|
||||
[0, 150, 1_100, CLAP_MS].map(
|
||||
(ms, index) =>
|
||||
[transform, opacity[index]!, ms] as [string, number, number],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function Burst({
|
||||
colors,
|
||||
onEnd,
|
||||
}: {
|
||||
colors: string[];
|
||||
onEnd: () => void;
|
||||
}): ReactElement {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const dots = root.current!.querySelectorAll<HTMLElement>("[data-dot]");
|
||||
const animations = [...dots].map((dot, index) =>
|
||||
play(
|
||||
dot,
|
||||
[
|
||||
{ transform: `rotate(${index * 45}deg) translate(0)`, opacity: 1 },
|
||||
{
|
||||
transform: `rotate(${index * 45}deg) translate(2.4em) scale(0.4)`,
|
||||
opacity: 0,
|
||||
},
|
||||
],
|
||||
{ duration: 600, easing: "ease-out", delay: IMPACT_MS },
|
||||
),
|
||||
);
|
||||
const ring = root.current!.querySelector<HTMLElement>("[data-ring]")!;
|
||||
animations.push(
|
||||
play(
|
||||
ring,
|
||||
[
|
||||
{ transform: "scale(0.2)", opacity: 0.8 },
|
||||
{ transform: "scale(1)", opacity: 0 },
|
||||
],
|
||||
{ duration: 400, easing: "ease-out", delay: IMPACT_MS },
|
||||
),
|
||||
);
|
||||
const timer = setTimeout(onEnd, IMPACT_MS + 600);
|
||||
return () => {
|
||||
animations.forEach((animation) => animation.cancel());
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [onEnd]);
|
||||
return (
|
||||
<div
|
||||
aria-label="High five!"
|
||||
className="absolute left-[-1.25em] top-[-1.25em] h-[2.5em] w-[2.5em]"
|
||||
ref={root}
|
||||
role="img"
|
||||
>
|
||||
<i
|
||||
className="absolute inset-0 rounded-full border-2 opacity-0"
|
||||
data-ring=""
|
||||
style={{ borderColor: colors[0] }}
|
||||
/>
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<i
|
||||
className="absolute left-1/2 top-1/2 -ml-[0.225em] -mt-[0.225em] h-[0.45em] w-[0.45em] rounded-full opacity-0"
|
||||
data-dot=""
|
||||
key={index}
|
||||
style={{ background: colors[index % colors.length] }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { IDLE_MS, reduced } from "./tokens";
|
||||
import type { CursorPoint, Units } from "./wire";
|
||||
|
||||
/** A position in px of the surface a cursor is drawn on. */
|
||||
export 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. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** A cursor position with the sender's clock, in ms. */
|
||||
type Sample = CursorPoint & { t: number };
|
||||
|
||||
const MIN_DELAY_MS = 16;
|
||||
const MAX_DELAY_MS = 300;
|
||||
/** How often the delay is reviewed against the lead the buffer kept. */
|
||||
const REVIEW_MS = 2_000;
|
||||
/** The lead the buffer should keep at every arrival; more than this is given back. */
|
||||
const SLACK_MS = 32;
|
||||
/** The most samples kept unplayed. */
|
||||
const MAX_SAMPLES = 256;
|
||||
/** How long the offset left by a hop or a reflow takes to decay, critically damped. */
|
||||
const SETTLE_S = 0.24;
|
||||
/** The longest frame the decay steps over. */
|
||||
const MAX_STEP_MS = 50;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class Glide {
|
||||
private samples: Sample[] = [];
|
||||
/** Local clock minus sender clock, the smallest seen: the least delayed delivery. */
|
||||
private offset = Infinity;
|
||||
private delay = MIN_DELAY_MS;
|
||||
/** The smallest lead the buffer had when a sample landed since the last review. */
|
||||
private lead = Infinity;
|
||||
private reviewedAt = 0;
|
||||
private lastNow = 0;
|
||||
/** What was aimed at last frame, to tell a shift of this page from the pointer's own motion. */
|
||||
private aimed: Placed | undefined;
|
||||
/** The offset still to decay and its velocity. */
|
||||
private residual: Point = { x: 0, y: 0 };
|
||||
private residualVelocity: Point = { x: 0, y: 0 };
|
||||
|
||||
/** 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 {
|
||||
const t = point.t ?? arrivedAt;
|
||||
const delivery = arrivedAt - t;
|
||||
const last = this.samples[this.samples.length - 1];
|
||||
if (last && t === last.t) {
|
||||
return;
|
||||
}
|
||||
if (last && (t < last.t || delivery < this.offset - MAX_DELAY_MS)) {
|
||||
this.samples = [];
|
||||
this.offset = Infinity;
|
||||
}
|
||||
if (this.offset === Infinity) {
|
||||
this.reviewedAt = arrivedAt;
|
||||
} else {
|
||||
this.adapt(t, arrivedAt);
|
||||
}
|
||||
const offset = Math.min(this.offset, delivery);
|
||||
if (offset < this.offset && this.offset !== Infinity) {
|
||||
this.delay = Math.min(MAX_DELAY_MS, this.delay + this.offset - offset);
|
||||
}
|
||||
this.offset = offset;
|
||||
this.samples.push({ ...point, t });
|
||||
if (this.samples.length > MAX_SAMPLES) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/** Where to draw at local time `now` on `space`, or undefined before the first sample. */
|
||||
draw(now: number, space: Space): Point | undefined {
|
||||
if (this.samples.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const T = this.playhead(now);
|
||||
const placed = this.place(T, space);
|
||||
const dt = Math.min(MAX_STEP_MS, now - this.lastNow) / 1000;
|
||||
const previous = this.aimed;
|
||||
this.aimed = placed;
|
||||
this.lastNow = now;
|
||||
if (previous && !space.camera) {
|
||||
const shift = sub(
|
||||
add(space.resolve(previous.sample), previous.extra),
|
||||
previous.target,
|
||||
);
|
||||
const jump = add(shift, this.hopped(previous.t, placed.t, space));
|
||||
this.residual = sub(this.residual, jump);
|
||||
}
|
||||
this.residual = spring(
|
||||
this.residual,
|
||||
this.residualVelocity,
|
||||
{ x: 0, y: 0 },
|
||||
dt,
|
||||
);
|
||||
if (length(this.residual) < 0.5 && length(this.residualVelocity) < 1) {
|
||||
this.residual = { x: 0, y: 0 };
|
||||
this.residualVelocity = { x: 0, y: 0 };
|
||||
}
|
||||
return add(placed.target, this.residual);
|
||||
}
|
||||
|
||||
/** Forgets the offset being decayed. */
|
||||
rest(): void {
|
||||
this.aimed = undefined;
|
||||
this.residual = { x: 0, y: 0 };
|
||||
this.residualVelocity = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
/** 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(from: number, to: number, space: Space): Point {
|
||||
let total = { x: 0, y: 0 };
|
||||
const samples = this.samples;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const before = samples[index - 1]!;
|
||||
const q = samples[index]!;
|
||||
if (q.t <= from || q.t > to || keyOf(before) === keyOf(q)) {
|
||||
continue;
|
||||
}
|
||||
total = add(
|
||||
total,
|
||||
sub(
|
||||
space.resolve(q),
|
||||
add(space.resolve(before), step(before, q, space)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 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(T: number, space: Space): Placed {
|
||||
const { p1, p2, u } = this.locate(T);
|
||||
const none = { x: 0, y: 0 };
|
||||
if (!p2 || u <= 0) {
|
||||
return { target: space.resolve(p1), sample: p1, extra: none, t: p1.t };
|
||||
}
|
||||
if (keyOf(p1) !== keyOf(p2)) {
|
||||
const extra = scale(step(p1, p2, space), u);
|
||||
return {
|
||||
target: add(space.resolve(p1), extra),
|
||||
sample: p1,
|
||||
extra,
|
||||
t: p1.t,
|
||||
};
|
||||
}
|
||||
const sample: Sample = {
|
||||
...p2,
|
||||
x: mix(p1.x, p2.x, u),
|
||||
y: mix(p1.y, p2.y, u),
|
||||
...(p1.dx !== undefined && p2.dx !== undefined
|
||||
? { dx: mix(p1.dx, p2.dx, u), dy: mix(p1.dy!, p2.dy!, u) }
|
||||
: {}),
|
||||
};
|
||||
return { target: space.resolve(sample), sample, extra: none, t: p1.t };
|
||||
}
|
||||
|
||||
/** 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(T: number): { p1: Sample; p2: Sample | undefined; u: number } {
|
||||
const samples = this.samples;
|
||||
let index = 0;
|
||||
while (index + 1 < samples.length && samples[index + 1]!.t <= T) {
|
||||
index += 1;
|
||||
}
|
||||
const p1 = samples[index]!;
|
||||
const p2 = samples[index + 1];
|
||||
return { p1, p2, u: p2 ? (T - p1.t) / (p2.t - p1.t) : 0 };
|
||||
}
|
||||
|
||||
private playhead(now: number): number {
|
||||
return now - this.offset - this.delay;
|
||||
}
|
||||
|
||||
/** Grows the delay by how far behind the playhead this sample landed, if it did, and reviews the delay every two seconds. */
|
||||
private adapt(t: number, arrivedAt: number): void {
|
||||
const lead = t - this.playhead(arrivedAt);
|
||||
if (lead < 0) {
|
||||
this.delay = Math.min(MAX_DELAY_MS, this.delay - lead);
|
||||
}
|
||||
this.lead = Math.min(this.lead, Math.max(0, lead));
|
||||
if (arrivedAt - this.reviewedAt >= REVIEW_MS) {
|
||||
if (this.lead > SLACK_MS) {
|
||||
this.delay = Math.max(
|
||||
MIN_DELAY_MS,
|
||||
this.delay - (this.lead - SLACK_MS) / 2,
|
||||
);
|
||||
}
|
||||
this.lead = Infinity;
|
||||
this.reviewedAt = arrivedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class Replay {
|
||||
private readonly glide = new Glide();
|
||||
private frame = 0;
|
||||
private idle: ReturnType<typeof setTimeout> | undefined;
|
||||
private last: Point | undefined;
|
||||
private faded = false;
|
||||
/** The newest sample, drawn as is under reduced motion. */
|
||||
private rest: CursorPoint | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly space: Space,
|
||||
private readonly onFrame: (at: Point | undefined) => void,
|
||||
) {}
|
||||
|
||||
follow(point: CursorPoint): void {
|
||||
this.rest = point;
|
||||
if (!reduced()) {
|
||||
this.glide.push(point, performance.now());
|
||||
}
|
||||
this.wake();
|
||||
}
|
||||
|
||||
/** Shows the cursor where it was, draws it every frame, and starts the `IDLE_MS` to fading it. */
|
||||
wake(): void {
|
||||
clearTimeout(this.idle);
|
||||
this.idle = setTimeout(this.fade, IDLE_MS);
|
||||
if (this.faded) {
|
||||
this.faded = false;
|
||||
this.onFrame(this.last);
|
||||
}
|
||||
if (this.frame === 0) {
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
cancelAnimationFrame(this.frame);
|
||||
this.frame = 0;
|
||||
clearTimeout(this.idle);
|
||||
}
|
||||
|
||||
private readonly fade = (): void => {
|
||||
this.faded = true;
|
||||
this.glide.rest();
|
||||
this.onFrame(undefined);
|
||||
};
|
||||
|
||||
/** Draws where the samples fall on the surface now, each frame while the cursor shows. */
|
||||
private readonly draw = (): void => {
|
||||
if (this.faded) {
|
||||
this.frame = 0;
|
||||
return;
|
||||
}
|
||||
const at = reduced()
|
||||
? this.rest && this.space.resolve(this.rest)
|
||||
: this.glide.draw(performance.now(), this.space);
|
||||
if (at && !(this.last && this.last.x === at.x && this.last.y === at.y)) {
|
||||
this.last = at;
|
||||
this.onFrame(at);
|
||||
}
|
||||
this.frame = requestAnimationFrame(this.draw);
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a sender time falls: the target in px, the sample resolved for it, the pointer's step added on top and the sample's clock. */
|
||||
interface Placed {
|
||||
target: Point;
|
||||
sample: CursorPoint;
|
||||
extra: Point;
|
||||
t: number;
|
||||
}
|
||||
|
||||
function keyOf(sample: CursorPoint): string | undefined {
|
||||
return sample.anchor?.join("/");
|
||||
}
|
||||
|
||||
/** The pointer's own step from `a` to `b` on the sender's page, in px of this one. */
|
||||
function step(a: CursorPoint, b: CursorPoint, space: Space): Point {
|
||||
return sub(
|
||||
space.resolve({ x: b.x, y: b.y }),
|
||||
space.resolve({ x: a.x, y: a.y }),
|
||||
);
|
||||
}
|
||||
|
||||
function mix(a: number, b: number, u: number): number {
|
||||
return a + (b - a) * u;
|
||||
}
|
||||
|
||||
function add(a: Point, b: Point): Point {
|
||||
return { x: a.x + b.x, y: a.y + b.y };
|
||||
}
|
||||
|
||||
function sub(a: Point, b: Point): Point {
|
||||
return { x: a.x - b.x, y: a.y - b.y };
|
||||
}
|
||||
|
||||
function scale(a: Point, by: number): Point {
|
||||
return { x: a.x * by, y: a.y * by };
|
||||
}
|
||||
|
||||
function length(a: Point): number {
|
||||
return Math.hypot(a.x, a.y);
|
||||
}
|
||||
|
||||
/** One step of a critically damped spring from `from` toward `to` over `dt` seconds, settling within SETTLE_S; `velocity` is updated in place. */
|
||||
function spring(from: Point, velocity: Point, to: Point, dt: number): Point {
|
||||
const omega = -Math.log(0.02) / SETTLE_S;
|
||||
const next = { x: from.x, y: from.y };
|
||||
for (const axis of ["x", "y"] as const) {
|
||||
velocity[axis] +=
|
||||
((to[axis] - from[axis]) * omega * omega - 2 * omega * velocity[axis]) *
|
||||
dt;
|
||||
next[axis] = from[axis] + velocity[axis] * dt;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { colorOf } from "../colors";
|
||||
import type { JsonObject } from "../shared/shared";
|
||||
import { shared, useBroadcast } from "../shared/shared";
|
||||
import { EMOJI, Emoji, Legend, Stream } from "./emotes";
|
||||
import { Meeting, Wiggle } from "./gestures";
|
||||
import { AtPointer, Layer } from "./glyph";
|
||||
import type { Puppet } from "./hand";
|
||||
import { Burst, HandSlot } from "./hand";
|
||||
import type { Point, Replay, Space } from "./motion";
|
||||
import { CLAP_MS, HOLD_MS } from "./tokens";
|
||||
import type { CursorPoint } from "./wire";
|
||||
import { emoteOf, highFiveOf, regionOf, waveOf } from "./wire";
|
||||
|
||||
/** One collaborator's cursor as the reactions meet it: where it is drawn now in px of the surface, or undefined while it is not, its hand and arrow, and its replay. */
|
||||
export interface Peer {
|
||||
at: Point | undefined;
|
||||
puppet: Puppet | null;
|
||||
replay: Replay;
|
||||
}
|
||||
|
||||
/** My pointer over the surface as its last event left it: px, when in ms, and whether a button is held. */
|
||||
export interface Pointer extends Point {
|
||||
t: number;
|
||||
down: boolean;
|
||||
}
|
||||
|
||||
/** Numbers everything shown, across every `Reactions`, for React keys. */
|
||||
let serial = 0;
|
||||
|
||||
/**
|
||||
* Emotes, high fives and waves over any surface: `1`–`6` sends an emoji from
|
||||
* my pointer and `E` shows the legend, resting on a peer's drawn cursor
|
||||
* offers a high five that a click or `Space` fires, and a wiggle waves. The
|
||||
* three broadcasts go out and come in here, and everyone's hands are driven
|
||||
* through `peers`.
|
||||
*/
|
||||
export function Reactions({
|
||||
legend,
|
||||
onLegend,
|
||||
onWave,
|
||||
peers,
|
||||
pointer,
|
||||
region,
|
||||
space,
|
||||
toWire,
|
||||
}: {
|
||||
legend: Point | undefined;
|
||||
/** Where the legend should show, or close (undefined); the host keeps it. */
|
||||
onLegend: (at: Point | undefined) => void;
|
||||
/** My wave starts or ends. */
|
||||
onWave?: (on: boolean) => void;
|
||||
peers: ReadonlyMap<string, Peer>;
|
||||
pointer: Pointer | undefined;
|
||||
region?: string;
|
||||
space: Space;
|
||||
/** A point of the surface as the wire point everyone else reads, or nothing when it has none. */
|
||||
toWire: (at: Point) => CursorPoint | undefined;
|
||||
}): ReactElement {
|
||||
const [partner, setPartner] = useState<string>();
|
||||
const [shown, setShown] = useState<
|
||||
{ key: number; at: Point; node: ReactNode }[]
|
||||
>([]);
|
||||
const [meeting] = useState(() => new Meeting(peers, setPartner));
|
||||
const [wiggle] = useState(() => new Wiggle());
|
||||
const [stream] = useState(() => new Stream());
|
||||
const hand = useRef<Puppet>(null);
|
||||
const latest = useRef(pointer);
|
||||
latest.current = pointer;
|
||||
/** The pointer the legend opened at. */
|
||||
const shownAt = useRef<Pointer>();
|
||||
/** The last pair that clapped and when. */
|
||||
const lastClap = useRef({ pair: "", t: 0 });
|
||||
/** While I wave: the timer that ends it and the payload its end is sent with. */
|
||||
const waving = useRef<{
|
||||
release: ReturnType<typeof setTimeout>;
|
||||
payload: JsonObject;
|
||||
}>();
|
||||
const me = shared.me.user.id;
|
||||
|
||||
const show = useCallback(
|
||||
(at: Point, node: (done: () => void) => ReactNode) => {
|
||||
const key = (serial += 1);
|
||||
const done = () =>
|
||||
setShown((all) => all.filter((item) => item.key !== key));
|
||||
setShown((all) => [...all, { key, at, node: node(done) }]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const float = useCallback(
|
||||
(at: Point, emoji: string) => {
|
||||
const turn = stream.next(performance.now());
|
||||
show(at, (done) => <Emoji emoji={emoji} onEnd={done} turn={turn} />);
|
||||
},
|
||||
[show, stream],
|
||||
);
|
||||
|
||||
/** Both participants' hands meet at `target`; theirs on their layer, mine at my pointer. */
|
||||
const highFive = useCallback(
|
||||
(target: Point, ids: [string, string]) => {
|
||||
const pair = [...ids].sort().join(" ");
|
||||
const now = performance.now();
|
||||
if (
|
||||
pair === lastClap.current.pair &&
|
||||
now - lastClap.current.t < CLAP_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastClap.current = { pair, t: now };
|
||||
const starts = ids.map((id) =>
|
||||
id === me ? latest.current : peers.get(id)?.at,
|
||||
);
|
||||
const left =
|
||||
starts[0] && starts[1] && starts[0].x !== starts[1].x
|
||||
? starts[0].x < starts[1].x
|
||||
: true;
|
||||
ids.forEach((id, index) => {
|
||||
const start = starts[index];
|
||||
const puppet = id === me ? hand.current : peers.get(id)?.puppet;
|
||||
if (start && puppet) {
|
||||
void puppet.clap(
|
||||
{ x: target.x - start.x, y: target.y - start.y },
|
||||
(index === 0) === left ? -1 : 1,
|
||||
);
|
||||
}
|
||||
});
|
||||
const colors = ids.map((id) => colorOf(id).backgroundColor);
|
||||
show(target, (done) => <Burst colors={colors} onEnd={done} />);
|
||||
if (ids.includes(me)) {
|
||||
meeting.done(ids[0] === me ? ids[1] : ids[0]);
|
||||
}
|
||||
},
|
||||
[me, meeting, peers, show],
|
||||
);
|
||||
|
||||
const fire = useCallback(() => {
|
||||
const target = meeting.point();
|
||||
const partner = meeting.partner;
|
||||
const point = target && toWire(target);
|
||||
if (!point || partner === undefined) {
|
||||
return;
|
||||
}
|
||||
shared.broadcast("cursor:high-five", { ...point, with: partner });
|
||||
highFive(target, [me, partner]);
|
||||
}, [highFive, me, meeting, toWire]);
|
||||
|
||||
const emote = useCallback(
|
||||
(emoji: string) => {
|
||||
const point = latest.current && toWire(latest.current);
|
||||
if (point) {
|
||||
shared.broadcast("cursor:emote", { ...point, emoji });
|
||||
float(latest.current!, emoji);
|
||||
}
|
||||
},
|
||||
[float, toWire],
|
||||
);
|
||||
|
||||
const endWave = useCallback(() => {
|
||||
wiggle.end();
|
||||
const wave = waving.current;
|
||||
if (wave) {
|
||||
clearTimeout(wave.release);
|
||||
waving.current = undefined;
|
||||
hand.current?.wave(false);
|
||||
onWave?.(false);
|
||||
shared.broadcast("cursor:wave", { ...wave.payload, on: false });
|
||||
}
|
||||
}, [onWave, wiggle]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pointer) {
|
||||
endWave();
|
||||
meeting.mine(undefined);
|
||||
return;
|
||||
}
|
||||
meeting.mine(pointer);
|
||||
hand.current?.track(pointer.x, pointer.t);
|
||||
if (pointer.down) {
|
||||
endWave();
|
||||
} else if (wiggle.push(pointer.x, pointer.y, pointer.t)) {
|
||||
const payload = waving.current?.payload ?? regionOf(region);
|
||||
if (waving.current) {
|
||||
clearTimeout(waving.current.release);
|
||||
} else {
|
||||
hand.current?.wave(true);
|
||||
onWave?.(true);
|
||||
shared.broadcast("cursor:wave", { ...payload, on: true });
|
||||
}
|
||||
waving.current = { release: setTimeout(endWave, HOLD_MS), payload };
|
||||
}
|
||||
}, [endWave, meeting, onWave, pointer, region, wiggle]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (legend && shownAt.current !== pointer) {
|
||||
onLegend(undefined);
|
||||
}
|
||||
}, [legend, onLegend, pointer]);
|
||||
|
||||
const ending = useRef(endWave);
|
||||
ending.current = endWave;
|
||||
useEffect(
|
||||
() => () => {
|
||||
ending.current();
|
||||
meeting.stop();
|
||||
},
|
||||
[meeting],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (partner === undefined) {
|
||||
return;
|
||||
}
|
||||
const theirs = peers.get(partner)?.puppet;
|
||||
const mine = hand.current;
|
||||
const side = meeting.side();
|
||||
mine?.arm(true, side);
|
||||
theirs?.arm(true, -side as 1 | -1);
|
||||
return () => {
|
||||
theirs?.arm(false);
|
||||
mine?.arm(false);
|
||||
};
|
||||
}, [meeting, partner, peers]);
|
||||
|
||||
const pointing = pointer !== undefined;
|
||||
useLayoutEffect(() => {
|
||||
if (!pointing) {
|
||||
return;
|
||||
}
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const key = plainKey(event);
|
||||
const emoji = EMOJI[Number(key) - 1];
|
||||
if (key === "e" || key === "E") {
|
||||
shownAt.current = latest.current;
|
||||
onLegend(legend ? undefined : latest.current);
|
||||
} else if (emoji !== undefined) {
|
||||
emote(emoji);
|
||||
if (legend) {
|
||||
onLegend(undefined);
|
||||
}
|
||||
} else if (legend && key === "Escape") {
|
||||
onLegend(undefined);
|
||||
} else if (key === " " && meeting.partner !== undefined) {
|
||||
fire();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("click", fire);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("click", fire);
|
||||
};
|
||||
}, [emote, fire, legend, meeting, onLegend, pointing]);
|
||||
|
||||
useBroadcast("cursor:emote", (payload, from) => {
|
||||
const emote = emoteOf(payload, region, space.units);
|
||||
if (emote && from.id !== me && EMOJI.includes(emote.emoji)) {
|
||||
peers.get(from.id)?.replay.wake();
|
||||
float(peers.get(from.id)?.at ?? space.resolve(emote), emote.emoji);
|
||||
}
|
||||
});
|
||||
|
||||
useBroadcast("cursor:high-five", (payload, from) => {
|
||||
const five = highFiveOf(payload, region, space.units);
|
||||
if (five && from.id !== me) {
|
||||
peers.get(from.id)?.replay.wake();
|
||||
peers.get(five.with)?.replay.wake();
|
||||
highFive(space.resolve(five), [from.id, five.with]);
|
||||
}
|
||||
});
|
||||
|
||||
useBroadcast("cursor:wave", (payload, from) => {
|
||||
const wave = waveOf(payload, region);
|
||||
if (wave) {
|
||||
peers.get(from.id)?.replay.wake();
|
||||
peers.get(from.id)?.puppet?.wave(wave.on);
|
||||
}
|
||||
});
|
||||
|
||||
const color = colorOf(me).backgroundColor;
|
||||
return (
|
||||
<>
|
||||
{shown.map(({ key, at, node }) => (
|
||||
<Layer at={at} key={key}>
|
||||
{node}
|
||||
</Layer>
|
||||
))}
|
||||
{pointer && (
|
||||
<Layer at={pointer}>
|
||||
<HandSlot color={color} ref={hand} />
|
||||
</Layer>
|
||||
)}
|
||||
<AtPointer at={legend}>
|
||||
{(leaving) => <Legend leaving={leaving} />}
|
||||
</AtPointer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The key of a plain press meant for the page: none while a modifier is held, the key repeats, something already handled it, or a field or button has the keys. */
|
||||
export function plainKey(event: KeyboardEvent): string | undefined {
|
||||
const target = event.target;
|
||||
return event.defaultPrevented ||
|
||||
event.repeat ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey ||
|
||||
(target instanceof HTMLElement &&
|
||||
(target.isContentEditable ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLButtonElement))
|
||||
? undefined
|
||||
: event.key;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { COLORS } from "../colors";
|
||||
import { LABEL } from "./tokens";
|
||||
import type { TextSelection } from "./wire";
|
||||
import { regionOf } from "./wire";
|
||||
|
||||
const HIGHLIGHT = "sync-selection";
|
||||
|
||||
/** The page's selection as it lies inside `area`, or undefined when there is none, it reaches outside, or a rich text editor already shares it. */
|
||||
export function selectionIn(
|
||||
area: Element,
|
||||
region: string | undefined,
|
||||
): TextSelection | undefined {
|
||||
const selection = document.getSelection();
|
||||
const range =
|
||||
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
if (
|
||||
!range ||
|
||||
range.collapsed ||
|
||||
!area.contains(range.commonAncestorContainer) ||
|
||||
inEditor(range.commonAncestorContainer)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
from: pathTo(area, range.startContainer, range.startOffset),
|
||||
to: pathTo(area, range.endContainer, range.endOffset),
|
||||
hash: hash(range.toString()),
|
||||
...regionOf(region),
|
||||
};
|
||||
}
|
||||
|
||||
/** The range `selection` describes inside `area`, when the same text is there. */
|
||||
export function rangeOf(
|
||||
area: Element,
|
||||
selection: TextSelection,
|
||||
): Range | undefined {
|
||||
const from = pointAt(area, selection.from);
|
||||
const to = pointAt(area, selection.to);
|
||||
if (!from || !to) {
|
||||
return undefined;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.setStart(from.node, from.offset);
|
||||
range.setEnd(to.node, to.offset);
|
||||
return hash(range.toString()) === selection.hash ? range : undefined;
|
||||
}
|
||||
|
||||
/** Paints `range` as a text highlight in `color`, a palette background, until the returned function is called; without CSS highlights it paints nothing. */
|
||||
export function paint(range: Range, color: string): () => void {
|
||||
if (typeof Highlight !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
const highlight = highlightIn(color);
|
||||
highlight.add(range);
|
||||
return () => {
|
||||
highlight.delete(range);
|
||||
};
|
||||
}
|
||||
|
||||
function inEditor(node: Node): boolean {
|
||||
const element = node instanceof Element ? node : node.parentElement;
|
||||
return Boolean(element?.closest(".ProseMirror"));
|
||||
}
|
||||
|
||||
function pathTo(area: Node, node: Node, offset: number): number[] {
|
||||
const path = [offset];
|
||||
for (let step = node; step !== area; step = step.parentNode!) {
|
||||
path.unshift(
|
||||
Array.prototype.indexOf.call(step.parentNode!.childNodes, step),
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function pointAt(
|
||||
area: Node,
|
||||
path: readonly number[],
|
||||
): { node: Node; offset: number } | undefined {
|
||||
let node = area;
|
||||
for (const index of path.slice(0, -1)) {
|
||||
const child = node.childNodes[index];
|
||||
if (!child) {
|
||||
return undefined;
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
const offset = path[path.length - 1]!;
|
||||
const length =
|
||||
node.nodeType === Node.TEXT_NODE
|
||||
? (node as Text).length
|
||||
: node.childNodes.length;
|
||||
return offset <= length ? { node, offset } : undefined;
|
||||
}
|
||||
|
||||
/** FNV-1a over the text's code units, as eight hex digits. */
|
||||
function hash(text: string): string {
|
||||
let value = 0x811c9dc5;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
value = Math.imul(value ^ text.charCodeAt(index), 0x01000193);
|
||||
}
|
||||
return (value >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
let styled = false;
|
||||
|
||||
/** The one highlight per palette colour; the first one styles them all. */
|
||||
function highlightIn(color: string): Highlight {
|
||||
const name = `${HIGHLIGHT}-${color.slice(1)}`;
|
||||
let highlight = CSS.highlights.get(name);
|
||||
if (!highlight) {
|
||||
highlight = new Highlight();
|
||||
CSS.highlights.set(name, highlight);
|
||||
}
|
||||
if (!styled) {
|
||||
styled = true;
|
||||
const sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync(
|
||||
COLORS.map(
|
||||
({ backgroundColor }) =>
|
||||
`::highlight(${HIGHLIGHT}-${backgroundColor.slice(1)}) { background-color: ${backgroundColor}${LABEL.alpha}; }`,
|
||||
).join("\n"),
|
||||
);
|
||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
||||
}
|
||||
return highlight;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/** How long a cursor stays after its last move before it fades; the same as a board's `collaboratorInactiveTimeoutMs`. */
|
||||
export const IDLE_MS = 30_000;
|
||||
/** A small pull-back before a move. */
|
||||
export const ANTICIPATE_MS = 120;
|
||||
/** How long anything takes to leave. */
|
||||
const EXIT_MS = 200;
|
||||
/** How long a hand stays after the clap, and how long a wave outlives its last turn. */
|
||||
export const HOLD_MS = 600;
|
||||
export const EMOTE_MS = 1_400;
|
||||
export const CLAP_MS = 1_400;
|
||||
/** How long a cursor, a message or my chat input stays to pop out. */
|
||||
export const LINGER_MS = 150;
|
||||
|
||||
const REDUCED_MOTION =
|
||||
typeof matchMedia === "function"
|
||||
? matchMedia("(prefers-reduced-motion: reduce)")
|
||||
: undefined;
|
||||
|
||||
/** Whether the viewer asked for reduced motion. */
|
||||
export function reduced(): boolean {
|
||||
return REDUCED_MOTION?.matches === true;
|
||||
}
|
||||
|
||||
export const POP_IN =
|
||||
"animate-in fade-in duration-150 ease-out motion-reduce:animate-none";
|
||||
export const POP_OUT =
|
||||
"animate-out fade-out fill-mode-forwards duration-200 ease-out motion-reduce:animate-none";
|
||||
/** Where the name and message hang from the arrow's tip, and their size. */
|
||||
export const BESIDE_ARROW =
|
||||
"absolute left-[1em] top-[1.5em] w-max max-w-[16em] text-[0.75em]";
|
||||
/** The bubble the name, the message and my chat input share. */
|
||||
export const BUBBLE =
|
||||
"rounded-[1em] rounded-tl-[0.3em] px-[0.6em] py-[0.3em] leading-snug shadow-md";
|
||||
/** The neutral pill the key legend and the hint share. */
|
||||
export const PILL =
|
||||
"rounded-full bg-white text-neutral-800 shadow-lg shadow-black/10 ring-1 ring-black/[0.08] dark:bg-neutral-800 dark:text-neutral-100 dark:shadow-black/40 dark:ring-white/10";
|
||||
export const KEY =
|
||||
"rounded-[0.3em] bg-black/[0.08] px-[0.4em] font-mono font-medium dark:bg-white/15";
|
||||
/** `BUBBLE` as CSS values, for a name pill drawn without these classes; `tail` is the corner nearest what the pill hangs from, `alpha` the opacity a text selection is painted at. */
|
||||
export const LABEL = {
|
||||
fontSize: "0.75rem",
|
||||
padding: "0.3em 0.6em",
|
||||
radius: "1em",
|
||||
tail: "0.3em",
|
||||
alpha: "4d",
|
||||
};
|
||||
|
||||
export interface Ease {
|
||||
duration: number;
|
||||
easing: string;
|
||||
}
|
||||
|
||||
/** Arrives with a small overshoot. */
|
||||
export const SNAP: Ease = { duration: 320, easing: spring(0.62) };
|
||||
/** Arrives almost without overshoot. */
|
||||
export const SETTLE: Ease = { duration: 420, easing: spring(0.8) };
|
||||
/** Arrives with a visible bounce. */
|
||||
export const BOUNCE: Ease = { duration: 560, easing: spring(0.45) };
|
||||
export const FADE: Ease = { duration: EXIT_MS, easing: "ease-out" };
|
||||
|
||||
/** A damped spring from 0 to 1 as a CSS `linear()` easing, settled within 2 % at the end of the duration; `zeta` under 1 overshoots. */
|
||||
export function spring(zeta: number): string {
|
||||
const samples = 40;
|
||||
const points: string[] = [];
|
||||
for (let index = 0; index <= samples; index += 1) {
|
||||
const value = index === samples ? 1 : springAt(zeta, index / samples);
|
||||
points.push(value.toFixed(4));
|
||||
}
|
||||
return `linear(${points.join(", ")})`;
|
||||
}
|
||||
|
||||
/** The position of a spring of `zeta` at `t` of its settle time, 0 to 1. */
|
||||
export function springAt(zeta: number, t: number): number {
|
||||
const omega = -Math.log(0.02) / zeta;
|
||||
const damped = omega * Math.sqrt(1 - zeta * zeta);
|
||||
return (
|
||||
1 -
|
||||
Math.exp(-zeta * omega * t) *
|
||||
(Math.cos(damped * t) +
|
||||
(zeta / Math.sqrt(1 - zeta * zeta)) * Math.sin(damped * t))
|
||||
);
|
||||
}
|
||||
|
||||
/** Plays `keyframes` on the element and keeps the final state; under reduced motion it plays `still` instead, by default the final pose with only the opacity fading over 150 ms. */
|
||||
export function play(
|
||||
element: Element,
|
||||
keyframes: Keyframe[],
|
||||
ease: Ease & { delay?: number },
|
||||
still = calm(keyframes, ease.duration),
|
||||
): Animation {
|
||||
return element.animate(reduced() ? still : keyframes, {
|
||||
fill: "both",
|
||||
...ease,
|
||||
});
|
||||
}
|
||||
|
||||
function calm(keyframes: Keyframe[], duration: number): Keyframe[] {
|
||||
const {
|
||||
offset: _offset,
|
||||
easing: _easing,
|
||||
...pose
|
||||
} = keyframes[keyframes.length - 1]!;
|
||||
const from = { ...pose, opacity: keyframes[0]!.opacity ?? pose.opacity };
|
||||
return [
|
||||
{ ...from, offset: 0 },
|
||||
{ ...pose, offset: Math.min(1, 150 / duration) },
|
||||
{ ...pose, offset: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
/** `value` while it is set, then the last one for `LINGER_MS` more as `leaving`. */
|
||||
export function useLinger<T>(value: T | undefined): {
|
||||
value: T | undefined;
|
||||
leaving: boolean;
|
||||
} {
|
||||
const last = useRef(value);
|
||||
const [gone, setGone] = useState(value === undefined);
|
||||
useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
last.current = value;
|
||||
setGone(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setGone(true), LINGER_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value]);
|
||||
if (value !== undefined) {
|
||||
return { value, leaving: false };
|
||||
}
|
||||
return { value: gone ? undefined : last.current, leaving: !gone };
|
||||
}
|
||||
|
||||
export function cx(
|
||||
...parts: ReadonlyArray<string | false | null | undefined>
|
||||
): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { JsonObject } from "../shared/shared";
|
||||
import { shared } from "../shared/shared";
|
||||
|
||||
/** 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. */
|
||||
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 `<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. */
|
||||
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 `<Cursors>` 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 `<Cursors>` 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 `<Cursors>` 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<typeof mine> = {}): 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)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type {
|
||||
Collaborator,
|
||||
DocData,
|
||||
Fields,
|
||||
FieldValue,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
MyState,
|
||||
SharedCollection,
|
||||
SharedDoc,
|
||||
SharedStatus,
|
||||
User,
|
||||
} from "./shared/shared";
|
||||
export {
|
||||
arrayRemove,
|
||||
arrayUnion,
|
||||
deleteField,
|
||||
increment,
|
||||
myState,
|
||||
shared,
|
||||
sharedText,
|
||||
useBroadcast,
|
||||
useCollaborators,
|
||||
useMe,
|
||||
useMyState,
|
||||
useSharedCollection,
|
||||
useSharedDoc,
|
||||
useSharedStatus,
|
||||
useUndo,
|
||||
} from "./shared/shared";
|
||||
export type {
|
||||
Cursor,
|
||||
CursorSpace,
|
||||
CursorsProps,
|
||||
SharedInputProps,
|
||||
SharedTextareaProps,
|
||||
} from "./components/components";
|
||||
export {
|
||||
CursorArrow,
|
||||
Cursors,
|
||||
SharedInput,
|
||||
SharedTextarea,
|
||||
} from "./components/components";
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
CursorGlyph,
|
||||
Glide,
|
||||
IDLE_MS,
|
||||
LABEL,
|
||||
Layer,
|
||||
Reactions,
|
||||
Replay,
|
||||
colorOf,
|
||||
cursorOf,
|
||||
messageOf,
|
||||
opaque,
|
||||
opaqueField,
|
||||
pointOf,
|
||||
useOpaqueField,
|
||||
} from "./internal.ts";
|
||||
@@ -0,0 +1,14 @@
|
||||
export { colorOf } from "./colors";
|
||||
export { CursorGlyph, Layer } from "./cursors/glyph";
|
||||
export type { Puppet } from "./cursors/hand";
|
||||
export type { Point, Space } from "./cursors/motion";
|
||||
export { Glide, Replay } from "./cursors/motion";
|
||||
export type { Peer, Pointer } from "./cursors/reactions";
|
||||
export { Reactions } from "./cursors/reactions";
|
||||
export { IDLE_MS, LABEL } from "./cursors/tokens";
|
||||
export type { CursorPoint } from "./cursors/wire";
|
||||
export { cursorOf, messageOf, pointOf } from "./cursors/wire";
|
||||
export type { OpaqueNode, OpaqueTextRun, OpaqueY } from "./shared/fields";
|
||||
export { opaque } from "./shared/fields";
|
||||
export type { OpaqueField } from "./shared/opaqueField";
|
||||
export { opaqueField, useOpaqueField } from "./shared/opaqueField";
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
import type * as Y from "yjs";
|
||||
import { warnOnce } from "./shared/warn";
|
||||
|
||||
/** The host's connection to the collaboration server. */
|
||||
export type RoomConnection = "connected" | "connecting" | "disconnected";
|
||||
|
||||
export interface RoomStatus {
|
||||
connected: boolean;
|
||||
synced: boolean;
|
||||
upstream?: RoomConnection;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export type RoomEvent = "status" | "synced" | "denied" | "closed";
|
||||
export type RoomListener = (payload?: unknown) => void;
|
||||
|
||||
/**
|
||||
* The handle `clickup.collab.open()` resolves with: a `Y.Doc` mirror of one
|
||||
* of the artifact's documents, its awareness, the host's Yjs module, the
|
||||
* session status and its events. A host may seed the awareness's local state
|
||||
* with `user`, which the kit then publishes instead of `clickup.getViewer()`.
|
||||
*/
|
||||
export interface Room {
|
||||
readonly doc: Y.Doc;
|
||||
readonly awareness: Awareness;
|
||||
readonly Y: typeof Y;
|
||||
readonly status: RoomStatus;
|
||||
on(event: RoomEvent, listener: RoomListener): (() => void) | void;
|
||||
off?(event: RoomEvent, listener: RoomListener): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface CollabApi {
|
||||
open(document?: string): Promise<{ data?: Room; error?: unknown }>;
|
||||
}
|
||||
|
||||
/** `unavailable` on a host without collaboration, for the page's life. */
|
||||
export type SessionState =
|
||||
| { kind: "opening" }
|
||||
| { kind: "open"; room: Room }
|
||||
| { kind: "unavailable" };
|
||||
|
||||
const RETRY_MS = 5_000;
|
||||
const RETRY_AFTER_DENIED_MS = 15_000;
|
||||
/** The global the sandbox compiler resolves `yjs` imports to. */
|
||||
const HOST_YJS = "__syncY";
|
||||
|
||||
export function hostRoom(): RoomSession {
|
||||
const { clickup } = globalThis as { clickup?: { collab?: CollabApi } };
|
||||
return new RoomSession(clickup?.collab);
|
||||
}
|
||||
|
||||
/**
|
||||
* One room over the page's lifetime, kept open: a failed open is retried
|
||||
* every 5 s, a `closed` room reopens at once, a `denied` one after 15 s, and
|
||||
* the page coming online retries at once. `ready()` resolves once the first
|
||||
* attempt has synced or failed, and stays resolved.
|
||||
*/
|
||||
export class RoomSession {
|
||||
state: SessionState = { kind: "opening" };
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly gate = new Gate();
|
||||
private detach: (() => void) | undefined;
|
||||
private opening = false;
|
||||
private retry: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
constructor(private readonly collab: CollabApi | undefined) {
|
||||
window.addEventListener("online", () => void this.open());
|
||||
void this.open();
|
||||
}
|
||||
|
||||
/** Fires on every state or room status change. */
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
ready(): Promise<void> {
|
||||
return this.gate.promise;
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.gate.open;
|
||||
}
|
||||
|
||||
/** One attempt at a time, and none while a room is open; whatever the host does wrong is a failed attempt. */
|
||||
private async open() {
|
||||
if (!this.collab) {
|
||||
this.unavailable();
|
||||
return;
|
||||
}
|
||||
if (this.opening || this.state.kind !== "opening") {
|
||||
return;
|
||||
}
|
||||
this.opening = true;
|
||||
clearTimeout(this.retry);
|
||||
try {
|
||||
const { data, error } = await this.collab.open();
|
||||
const code = (error as { code?: unknown } | null | undefined)?.code;
|
||||
if (code === "not-enabled" || code === "not-declared") {
|
||||
this.unavailable();
|
||||
warnOnce(
|
||||
"collab-refused",
|
||||
"Live collaboration is unavailable here: shared documents stay empty and presence shows only you.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.attach(roomOf(data));
|
||||
} catch {
|
||||
this.gate.release();
|
||||
this.retryIn(RETRY_MS);
|
||||
} finally {
|
||||
this.opening = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The host has no collaboration for this page: shared documents stay empty, presence shows only this viewer, and nothing waits or retries. */
|
||||
private unavailable() {
|
||||
this.state = { kind: "unavailable" };
|
||||
this.gate.release();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private retryIn(ms: number) {
|
||||
clearTimeout(this.retry);
|
||||
this.retry = setTimeout(() => void this.open(), ms);
|
||||
}
|
||||
|
||||
private attach(room: Room) {
|
||||
const synced = () => {
|
||||
if (room.status.synced) {
|
||||
this.gate.release();
|
||||
}
|
||||
this.notify();
|
||||
};
|
||||
const offs = [
|
||||
listen(room, "status", synced),
|
||||
listen(room, "synced", synced),
|
||||
listen(room, "denied", () => this.lose(room, RETRY_AFTER_DENIED_MS)),
|
||||
listen(room, "closed", () => this.lose(room, 0)),
|
||||
];
|
||||
this.detach = () => offs.forEach((off) => off());
|
||||
this.state = { kind: "open", room };
|
||||
(globalThis as Record<string, unknown>)[HOST_YJS] = room.Y;
|
||||
synced();
|
||||
}
|
||||
|
||||
/** Drops the room and opens again after `wait`; a room already dropped is ignored. */
|
||||
private lose(room: Room, wait: number) {
|
||||
if (this.state.kind !== "open" || this.state.room !== room) {
|
||||
return;
|
||||
}
|
||||
this.detach?.();
|
||||
this.detach = undefined;
|
||||
this.state = { kind: "opening" };
|
||||
this.gate.release();
|
||||
safeClose(room);
|
||||
this.notify();
|
||||
if (wait === 0) {
|
||||
void this.open();
|
||||
} else {
|
||||
this.retryIn(wait);
|
||||
}
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
const ROOM_FIELDS = ["doc", "awareness", "Y", "status", "on", "close"] as const;
|
||||
|
||||
/** The room the host handed over, if it has everything the kit relies on. */
|
||||
function roomOf(data: unknown): Room {
|
||||
const room = data as Record<string, unknown> | null | undefined;
|
||||
if (!room || ROOM_FIELDS.some((field) => room[field] == null)) {
|
||||
throw new Error("The host handed over no usable room.");
|
||||
}
|
||||
return room as unknown as Room;
|
||||
}
|
||||
|
||||
/** A promise that hooks suspend on until the room is usable. */
|
||||
class Gate {
|
||||
open = false;
|
||||
readonly promise: Promise<void>;
|
||||
private resolve!: () => void;
|
||||
|
||||
constructor() {
|
||||
this.promise = new Promise<void>((resolve) => {
|
||||
this.resolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
release() {
|
||||
this.open = true;
|
||||
this.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function listen(room: Room, event: RoomEvent, listener: RoomListener) {
|
||||
const off = room.on(event, listener);
|
||||
return () => {
|
||||
if (typeof off === "function") {
|
||||
off();
|
||||
} else {
|
||||
room.off?.(event, listener);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function safeClose(room: Room) {
|
||||
try {
|
||||
room.close();
|
||||
} catch {
|
||||
// A closed room may refuse a second close.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { User } from "../user";
|
||||
import { deepFreeze, isPlainObject, jsonValue } from "./fields";
|
||||
import type { JsonValue } from "./shared";
|
||||
|
||||
/** One broadcast on the wire, numbered per tab. */
|
||||
export interface WireEvent {
|
||||
seq: number;
|
||||
name: string;
|
||||
payload: JsonValue;
|
||||
}
|
||||
|
||||
export type BroadcastListener = (payload: JsonValue, from: User) => void;
|
||||
|
||||
/**
|
||||
* Ephemeral events: each `broadcast()` is one entry in the `events` the next
|
||||
* presence publish carries. A receiver delivers each entry newer than the
|
||||
* last it saw of that client; what a client already carried when first seen
|
||||
* is never delivered.
|
||||
*/
|
||||
export class Broadcasts {
|
||||
private seq = 0;
|
||||
private pending: WireEvent[] = [];
|
||||
/** The newest sequence had from each client of the room. */
|
||||
private readonly seen = new Map<number, number>();
|
||||
private readonly listeners = new Map<string, Set<BroadcastListener>>();
|
||||
|
||||
/** Validates a broadcast and queues it for the next publish. */
|
||||
queue(name: string, payload: JsonValue | undefined): void {
|
||||
if (typeof name !== "string" || name === "") {
|
||||
throw new Error(
|
||||
'broadcast() expects a name, a non-empty string like "buzz".',
|
||||
);
|
||||
}
|
||||
this.pending.push({
|
||||
seq: (this.seq += 1),
|
||||
name,
|
||||
payload: jsonValue(payload ?? null, "payload") as JsonValue,
|
||||
});
|
||||
}
|
||||
|
||||
/** The queued broadcasts for the publish, or undefined when there are none. */
|
||||
drain(): WireEvent[] | undefined {
|
||||
if (this.pending.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const events = this.pending;
|
||||
this.pending = [];
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Forgets every client, for a new room. */
|
||||
reset(): void {
|
||||
this.seen.clear();
|
||||
}
|
||||
|
||||
/** Notes a client first seen; whatever it carries came before. */
|
||||
arrived(clientId: number, events: unknown): void {
|
||||
const carried = eventsOf(events);
|
||||
this.seen.set(clientId, carried[carried.length - 1]?.seq ?? 0);
|
||||
}
|
||||
|
||||
/** Delivers the client's events newer than the last had, in order, to the listeners of their names. */
|
||||
deliver(clientId: number, events: unknown, from: User): void {
|
||||
for (const event of eventsOf(events)) {
|
||||
if (event.seq > (this.seen.get(clientId) ?? 0)) {
|
||||
this.seen.set(clientId, event.seq);
|
||||
const payload = deepFreeze(event.payload);
|
||||
this.listeners
|
||||
.get(event.name)
|
||||
?.forEach((listener) => listener(payload, from));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forget(clientId: number): void {
|
||||
this.seen.delete(clientId);
|
||||
}
|
||||
|
||||
on(name: string, listener: BroadcastListener): () => void {
|
||||
const listeners = this.listeners.get(name) ?? new Set<BroadcastListener>();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(name, listeners);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
this.listeners.delete(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The well-formed events of a wire state's `events`. */
|
||||
function eventsOf(events: unknown): WireEvent[] {
|
||||
return Array.isArray(events) ? events.filter(isWireEvent) : [];
|
||||
}
|
||||
|
||||
function isWireEvent(value: unknown): value is WireEvent {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
typeof value.seq === "number" &&
|
||||
typeof value.name === "string"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
import { idOfDocument, validateName } from "./paths";
|
||||
import type { DocData, JsonValue } from "./shared";
|
||||
import { warnOnce } from "./warn";
|
||||
|
||||
/** A document's fields as one flat map of dotted keys to JSON leaves, with Firestore's patch semantics; an empty nested object is itself a `{}` leaf, and the kit's own `#o` holds the document's place in its collection. */
|
||||
export type Leaves = Map<string, unknown>;
|
||||
|
||||
export const ORDER_KEY = "#o";
|
||||
|
||||
/** The enumerable symbol every read carries its snapshot on; a spread copies it along. */
|
||||
const BASE = Symbol.for("sync.base");
|
||||
|
||||
export interface Base {
|
||||
readonly path: string;
|
||||
readonly data: DocData;
|
||||
}
|
||||
|
||||
type Stamped = { [BASE]?: Base };
|
||||
|
||||
/** Marks `data`, before it is frozen, as the read of `path`. */
|
||||
function stamp(path: string, data: Record<string, unknown>): void {
|
||||
(data as Stamped)[BASE] = { path, data: data as DocData };
|
||||
}
|
||||
|
||||
/** The snapshot `fields` was spread from, if any. */
|
||||
export function baseOf(fields: unknown): Base | undefined {
|
||||
return isPlainObject(fields) ? (fields as Stamped)[BASE] : undefined;
|
||||
}
|
||||
|
||||
/** What an absent document reads as: just its `id`. */
|
||||
export function emptyDocument(path: string): DocData {
|
||||
const data: Record<string, unknown> = { id: idOfDocument(path) };
|
||||
stamp(path, data);
|
||||
return Object.freeze(data) as DocData;
|
||||
}
|
||||
|
||||
/** The patch markers, each an object of one `#` key. */
|
||||
export interface DeleteMarker {
|
||||
readonly "#delete": true;
|
||||
}
|
||||
|
||||
export interface TextMarker {
|
||||
readonly "#text": string;
|
||||
}
|
||||
|
||||
/** The Y type an opaque field holds for the kit that drives it. */
|
||||
export type OpaqueY = "xml" | "map";
|
||||
|
||||
/** An element of an `"xml"` seed. */
|
||||
export interface OpaqueElement {
|
||||
readonly type: string;
|
||||
readonly attrs?: Readonly<Record<string, JsonValue>>;
|
||||
readonly children?: readonly OpaqueNode[];
|
||||
}
|
||||
|
||||
/** One run of a text with its formatting, as a `Y.Text` delta insert. */
|
||||
export interface OpaqueTextRun {
|
||||
readonly insert: string;
|
||||
readonly attributes?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** A text of an `"xml"` seed. */
|
||||
export interface OpaqueText {
|
||||
readonly text: readonly OpaqueTextRun[];
|
||||
}
|
||||
|
||||
export type OpaqueNode = OpaqueElement | OpaqueText;
|
||||
|
||||
/** What an opaque field is created holding: the nodes of an `"xml"` fragment, or the records of a `"map"` by key. */
|
||||
export type OpaqueSeed =
|
||||
| readonly OpaqueNode[]
|
||||
| Readonly<Record<string, JsonValue>>;
|
||||
|
||||
export interface OpaqueMarker {
|
||||
readonly "#opaque": {
|
||||
readonly y: OpaqueY;
|
||||
readonly of: string;
|
||||
readonly seed?: OpaqueSeed;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IncrementMarker {
|
||||
readonly "#increment": number;
|
||||
}
|
||||
|
||||
export interface UnionMarker {
|
||||
readonly "#union": JsonValue[];
|
||||
}
|
||||
|
||||
export interface RemoveMarker {
|
||||
readonly "#remove": JsonValue[];
|
||||
}
|
||||
|
||||
export type Marker =
|
||||
| DeleteMarker
|
||||
| TextMarker
|
||||
| OpaqueMarker
|
||||
| IncrementMarker
|
||||
| UnionMarker
|
||||
| RemoveMarker;
|
||||
export type MarkerKey =
|
||||
| "#delete"
|
||||
| "#text"
|
||||
| "#opaque"
|
||||
| "#increment"
|
||||
| "#union"
|
||||
| "#remove";
|
||||
|
||||
const DELETE: DeleteMarker = Object.freeze({ "#delete": true as const });
|
||||
|
||||
export function deleteField(): DeleteMarker {
|
||||
return DELETE;
|
||||
}
|
||||
|
||||
export function sharedText(initial = ""): TextMarker {
|
||||
if (typeof initial !== "string") {
|
||||
throw new Error(
|
||||
`sharedText() expects a string to start with, got ${describe(initial)}.`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({ "#text": initial });
|
||||
}
|
||||
|
||||
/** Marker creating an opaque field: a Y type the owning kit drives, `of` naming that kit's library and only ever compared. `seed` is written into the payload as it is created, never into a field already holding these labels. */
|
||||
export function opaque(
|
||||
y: OpaqueY,
|
||||
of: string,
|
||||
seed?: OpaqueSeed,
|
||||
): OpaqueMarker {
|
||||
if (!isOpaqueLabels({ y, of })) {
|
||||
throw new Error(
|
||||
`opaque() expects "xml" or "map" and the name of the library driving it, got ${JSON.stringify(y)} and ${JSON.stringify(of)}.`,
|
||||
);
|
||||
}
|
||||
if (seed !== undefined && !isOpaqueSeed(y, seed)) {
|
||||
throw new Error(
|
||||
y === "xml"
|
||||
? `opaque("xml", …) starts with an array of nodes, each { type, attrs?, children? } or { text }, got ${describe(seed)}.`
|
||||
: `opaque("map", …) starts with an object of records by key, got ${describe(seed)}.`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
"#opaque": Object.freeze(seed === undefined ? { y, of } : { y, of, seed }),
|
||||
});
|
||||
}
|
||||
|
||||
/** True for a Y type name and a non-empty library name, the labels of an opaque field. */
|
||||
export function isOpaqueLabels(
|
||||
value: unknown,
|
||||
): value is { y: OpaqueY; of: string } {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
Object.keys(value).length === 2 &&
|
||||
(value.y === "xml" || value.y === "map") &&
|
||||
typeof value.of === "string" &&
|
||||
value.of.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** True for an opaque marker's payload: the labels, with a seed of the Y type's shape or none. */
|
||||
function isOpaquePayload(value: unknown): value is OpaqueMarker["#opaque"] {
|
||||
if (!isPlainObject(value)) {
|
||||
return false;
|
||||
}
|
||||
const { seed, ...labels } = value;
|
||||
return (
|
||||
isOpaqueLabels(labels) &&
|
||||
(seed === undefined || isOpaqueSeed(labels.y, seed))
|
||||
);
|
||||
}
|
||||
|
||||
function isOpaqueSeed(y: OpaqueY, seed: unknown): seed is OpaqueSeed {
|
||||
return y === "map" ? isPlainObject(seed) : Array.isArray(seed);
|
||||
}
|
||||
|
||||
export function increment(by: number): IncrementMarker {
|
||||
if (typeof by !== "number" || !Number.isFinite(by)) {
|
||||
throw new Error(
|
||||
`increment() expects a finite number, got ${describe(by)}.`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({ "#increment": by });
|
||||
}
|
||||
|
||||
export function arrayUnion(...values: JsonValue[]): UnionMarker {
|
||||
return Object.freeze({ "#union": values });
|
||||
}
|
||||
|
||||
export function arrayRemove(...values: JsonValue[]): RemoveMarker {
|
||||
return Object.freeze({ "#remove": values });
|
||||
}
|
||||
|
||||
/** Per marker key: the function that makes it, what its payload must be, and what a field created from it reads as. */
|
||||
const MARKERS = new Map<
|
||||
MarkerKey,
|
||||
{
|
||||
name: string;
|
||||
valid(payload: unknown): boolean;
|
||||
reads(payload: unknown): unknown;
|
||||
}
|
||||
>([
|
||||
[
|
||||
"#delete",
|
||||
{ name: "deleteField()", valid: (p) => p === true, reads: () => undefined },
|
||||
],
|
||||
[
|
||||
"#text",
|
||||
{
|
||||
name: "sharedText()",
|
||||
valid: (p) => typeof p === "string",
|
||||
reads: (p) => p,
|
||||
},
|
||||
],
|
||||
[
|
||||
"#opaque",
|
||||
{
|
||||
name: "opaque()",
|
||||
valid: isOpaquePayload,
|
||||
reads: (p) => {
|
||||
const { y, of } = p as OpaqueMarker["#opaque"];
|
||||
return opaque(y, of);
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"#increment",
|
||||
{ name: "increment()", valid: Number.isFinite, reads: (p) => p },
|
||||
],
|
||||
[
|
||||
"#union",
|
||||
{
|
||||
name: "arrayUnion()",
|
||||
valid: Array.isArray,
|
||||
reads: (p) => distinct(p as JsonValue[]),
|
||||
},
|
||||
],
|
||||
["#remove", { name: "arrayRemove()", valid: Array.isArray, reads: () => [] }],
|
||||
]);
|
||||
|
||||
/** The key of the marker `value` is, or undefined for anything else. */
|
||||
export function markerOf(value: unknown): MarkerKey | undefined {
|
||||
if (!isPlainObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
const key = keys[0] as MarkerKey;
|
||||
return keys.length === 1 && MARKERS.get(key)?.valid(value[key])
|
||||
? key
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** What a field created from the marker `value`, of the given `key`, reads as; a `deleteField()` reads as nothing. */
|
||||
export function markerReads(key: MarkerKey, value: unknown): unknown {
|
||||
return MARKERS.get(key)!.reads((value as Record<string, unknown>)[key]);
|
||||
}
|
||||
|
||||
/** `values` without repeats by canonical JSON, the first of each kept. */
|
||||
export function distinct(values: readonly unknown[]): unknown[] {
|
||||
const seen = new Set<string>();
|
||||
return values.filter((value) => {
|
||||
const key = canonicalKey(value);
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** One string per JSON value, the same for equal values: object keys sorted at every level. */
|
||||
export function canonicalKey(value: unknown): string {
|
||||
return JSON.stringify(value, (_, node: unknown) =>
|
||||
isPlainObject(node)
|
||||
? Object.fromEntries(
|
||||
Object.keys(node)
|
||||
.sort()
|
||||
.map((key) => [key, node[key]]),
|
||||
)
|
||||
: node,
|
||||
);
|
||||
}
|
||||
|
||||
/** A validated copy of a patch, `undefined` values skipped and every other value JSON or a marker; throws before anything is written. */
|
||||
export function normalizePatch(patch: unknown): Leaves {
|
||||
const marker = markerOf(patch);
|
||||
if (marker) {
|
||||
const { name } = MARKERS.get(marker)!;
|
||||
throw new Error(
|
||||
`Expected an object of fields like { title: ${name} }, got ${name} on its own.`,
|
||||
);
|
||||
}
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error(
|
||||
`Expected an object of fields like { title: "…" }, got ${describe(patch)}.`,
|
||||
);
|
||||
}
|
||||
const fields: Leaves = new Map();
|
||||
for (const key of Object.keys(patch)) {
|
||||
const value = patch[key];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
validateField(key);
|
||||
fields.set(key, normalizeLeaf(value, key));
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** A marker as is, its values checked as JSON where it carries some; anything else as JSON. */
|
||||
function normalizeLeaf(value: unknown, key: string): unknown {
|
||||
const marker = markerOf(value);
|
||||
if (marker === "#union" || marker === "#remove") {
|
||||
const values = (value as Record<string, unknown>)[marker];
|
||||
return Object.freeze({ [marker]: jsonValue(values, key) as JsonValue[] });
|
||||
}
|
||||
if (marker === "#opaque") {
|
||||
const { y, of, seed } = (value as OpaqueMarker)["#opaque"];
|
||||
return seed === undefined
|
||||
? value
|
||||
: opaque(y, of, jsonValue(seed, key) as OpaqueSeed);
|
||||
}
|
||||
return marker ? value : jsonValue(value, key);
|
||||
}
|
||||
|
||||
/** A `/`, a `#` or an empty segment anywhere in a dotted field key. */
|
||||
const INVALID_FIELD = /[/#]|^\.|\.\.|\.$/;
|
||||
/** A segment of a dotted field key that a missing value stringified to. */
|
||||
const MISSING_FIELD =
|
||||
/(?:^|\.)(?:undefined|null|NaN|\[object Object\])(?:\.|$)/;
|
||||
|
||||
function validateField(key: string) {
|
||||
if (key.length === 0) {
|
||||
throw new Error("A field name must be a non-empty string.");
|
||||
}
|
||||
if (INVALID_FIELD.test(key)) {
|
||||
throw new Error(
|
||||
`The field "${key}" may not contain "/" or "#", or start or end with ".".`,
|
||||
);
|
||||
}
|
||||
if (MISSING_FIELD.test(key)) {
|
||||
throw new Error(
|
||||
`The field "${key}" has a missing value in it; check the variable it was built from.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The dotted keys, bucketed by their number of dots. */
|
||||
type Dotted = string[][];
|
||||
|
||||
/** Where a patch lands: the leaves of a document, read as JSON; a plain `Map` or a live document. */
|
||||
export interface LeafStore {
|
||||
keys(): Iterable<string>;
|
||||
has(key: string): boolean;
|
||||
get(key: string): unknown;
|
||||
set(key: string, leaf: unknown): void;
|
||||
delete(key: string): void;
|
||||
}
|
||||
|
||||
/** Applies `patch` key by key; a later key wins where two overlap. Only the keys the patch touches are written. */
|
||||
export function applyPatch(fields: LeafStore, patch: Leaves): void {
|
||||
const dotted: Dotted = [];
|
||||
for (const key of fields.keys()) {
|
||||
noteDotted(dotted, key);
|
||||
}
|
||||
for (const [path, value] of patch) {
|
||||
if (markerOf(value) === "#delete") {
|
||||
deleteLeaf(fields, dotted, path);
|
||||
} else {
|
||||
setLeaf(fields, dotted, path, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a key and everything beneath it; a nested object left empty stays `{}`, as in Firestore. */
|
||||
function deleteLeaf(fields: LeafStore, dotted: Dotted, path: string) {
|
||||
const under = keysUnder(fields, dotted, path);
|
||||
const removed = fields.has(path) || under.length > 0;
|
||||
for (const key of [path, ...under]) {
|
||||
fields.delete(key);
|
||||
}
|
||||
const dot = path.lastIndexOf(".");
|
||||
if (removed && dot !== -1) {
|
||||
const parent = path.slice(0, dot);
|
||||
if (
|
||||
!fields.has(parent) &&
|
||||
keysUnder(fields, dotted, parent, 1).length === 0
|
||||
) {
|
||||
fields.set(parent, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces a key and everything beneath it with the leaves of `value`; anything in the way above it goes, except an array, which is a single value. */
|
||||
function setLeaf(
|
||||
fields: LeafStore,
|
||||
dotted: Dotted,
|
||||
path: string,
|
||||
value: unknown,
|
||||
) {
|
||||
for (
|
||||
let dot = path.indexOf(".");
|
||||
dot !== -1;
|
||||
dot = path.indexOf(".", dot + 1)
|
||||
) {
|
||||
const prefix = path.slice(0, dot);
|
||||
if (Array.isArray(fields.get(prefix))) {
|
||||
throw new Error(
|
||||
`Cannot update "${path}": "${prefix}" is an array. Arrays are single values; update the whole array, or use a keyed object or a subcollection for independently collaborative items.`,
|
||||
);
|
||||
}
|
||||
fields.delete(prefix);
|
||||
}
|
||||
const leaves: Leaves = new Map();
|
||||
forEachLeaf(path, value, (key, leaf) => leaves.set(key, leaf));
|
||||
for (const key of [path, ...keysUnder(fields, dotted, path)]) {
|
||||
if (!leaves.has(key)) {
|
||||
fields.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, leaf] of leaves) {
|
||||
fields.set(key, leaf);
|
||||
noteDotted(dotted, key);
|
||||
}
|
||||
}
|
||||
|
||||
/** The keys still present beneath `path`, at most `limit` of them. */
|
||||
function keysUnder(
|
||||
fields: LeafStore,
|
||||
dotted: Dotted,
|
||||
path: string,
|
||||
limit = Infinity,
|
||||
): string[] {
|
||||
const keys: string[] = [];
|
||||
for (let depth = countDots(path) + 1; depth < dotted.length; depth += 1) {
|
||||
for (const key of dotted[depth] ?? []) {
|
||||
if (fields.has(key) && isUnder(path, key) && keys.push(key) === limit) {
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function isUnder(path: string, key: string): boolean {
|
||||
return key.startsWith(path) && key[path.length] === ".";
|
||||
}
|
||||
|
||||
function noteDotted(dotted: Dotted, key: string) {
|
||||
const depth = countDots(key);
|
||||
if (depth > 0) {
|
||||
(dotted[depth] ??= []).push(key);
|
||||
}
|
||||
}
|
||||
|
||||
function countDots(key: string): number {
|
||||
let count = 0;
|
||||
for (
|
||||
let dot = key.indexOf(".");
|
||||
dot !== -1;
|
||||
dot = key.indexOf(".", dot + 1)
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Calls `each` with `value` as dotted leaves under `path`; an empty object is itself a `{}` leaf, a marker stays whole. */
|
||||
function forEachLeaf(
|
||||
path: string,
|
||||
value: unknown,
|
||||
each: (key: string, leaf: unknown) => void,
|
||||
) {
|
||||
if (!isPlainObject(value) || markerOf(value)) {
|
||||
each(path, value);
|
||||
return;
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length === 0) {
|
||||
each(path, {});
|
||||
}
|
||||
for (const key of keys) {
|
||||
forEachLeaf(`${path}.${key}`, value[key], each);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets `value` at the dotted `key` in `root`, creating objects on the way; keys must arrive sorted. A scalar in the way of nested keys loses to them, and `onConflict` names the field. */
|
||||
function nest(
|
||||
root: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
onConflict: (field: string) => void,
|
||||
): void {
|
||||
let node = root;
|
||||
let start = 0;
|
||||
for (let dot = key.indexOf("."); dot !== -1; dot = key.indexOf(".", start)) {
|
||||
const segment = key.slice(start, dot);
|
||||
const existing = own(node, segment);
|
||||
if (isPlainObject(existing)) {
|
||||
node = existing;
|
||||
} else {
|
||||
if (existing !== undefined) {
|
||||
onConflict(topOf(key));
|
||||
}
|
||||
node = setOwn(node, segment, {});
|
||||
}
|
||||
start = dot + 1;
|
||||
}
|
||||
setOwn(node, key.slice(start), value);
|
||||
}
|
||||
|
||||
function sortedFields(
|
||||
fields: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const ordered: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(fields).sort()) {
|
||||
if (key !== "id") {
|
||||
setOwn(ordered, key, fields[key]);
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function onlyMeta(fields: ReadonlySet<string>): boolean {
|
||||
for (const field of fields) {
|
||||
if (!field.startsWith("#")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The first segment of a dotted key. */
|
||||
export function topOf(key: string): string {
|
||||
const dot = key.indexOf(".");
|
||||
return dot === -1 ? key : key.slice(0, dot);
|
||||
}
|
||||
|
||||
export interface DocumentSnapshot {
|
||||
readonly id: string;
|
||||
readonly order: string;
|
||||
readonly data: DocData;
|
||||
}
|
||||
|
||||
/** A document's last data and the top-level fields that changed since. */
|
||||
export interface Previous {
|
||||
readonly data: DocData;
|
||||
readonly changed: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A frozen read of one document: `#` keys stripped, dotted keys nested, `id`
|
||||
* attached. Given `previous`, `fields` holds only the leaves of its changed
|
||||
* top-level fields; every other field keeps its object, and data that still
|
||||
* reads the same keeps its identity.
|
||||
*/
|
||||
export function documentSnapshot(
|
||||
path: string,
|
||||
fields: Leaves,
|
||||
previous?: Previous,
|
||||
): DocumentSnapshot {
|
||||
const id = idOfDocument(path);
|
||||
const order = fields.get(ORDER_KEY);
|
||||
let data = previous && onlyMeta(previous.changed) ? previous.data : undefined;
|
||||
if (!data) {
|
||||
const built: Record<string, unknown> = { ...previous?.data };
|
||||
for (const top of previous?.changed ?? []) {
|
||||
delete built[top];
|
||||
}
|
||||
const onConflict = (field: string) =>
|
||||
warnOnce(
|
||||
`conflict:${path}:${field}`,
|
||||
`"${field}" on "${path}" is stored both as a value and as nested fields; the nested fields win. Use a new field name when a field changes type.`,
|
||||
);
|
||||
for (const key of [...fields.keys()].sort()) {
|
||||
if (!key.startsWith("#")) {
|
||||
nest(built, key, jsonClone(fields.get(key)), onConflict);
|
||||
}
|
||||
}
|
||||
if (
|
||||
previous &&
|
||||
[...previous.changed].every((top) =>
|
||||
deepEqual(own(built, top), own(previous.data, top)),
|
||||
)
|
||||
) {
|
||||
data = previous.data;
|
||||
} else {
|
||||
const ordered = previous ? sortedFields(built) : built;
|
||||
setOwn(ordered, "id", id);
|
||||
stamp(path, ordered);
|
||||
data = deepFreeze(ordered) as DocData;
|
||||
}
|
||||
}
|
||||
return { id, order: typeof order === "string" ? order : "", data };
|
||||
}
|
||||
|
||||
/** `data` with `defaults` filling the top-level fields it lacks; a marker default reads as the field it would create. */
|
||||
export function withDefaults(
|
||||
data: DocData,
|
||||
defaults: Readonly<Record<string, unknown>> = {},
|
||||
): DocData {
|
||||
let filled: Record<string, unknown> | undefined;
|
||||
for (const key of Object.keys(defaults)) {
|
||||
const value = defaults[key];
|
||||
const marker = markerOf(value);
|
||||
const read = marker ? markerReads(marker, value) : jsonClone(value);
|
||||
if (own(data, key) === undefined && read !== undefined) {
|
||||
filled ??= { ...data };
|
||||
setOwn(filled, key, read);
|
||||
}
|
||||
}
|
||||
if (!filled) {
|
||||
return data;
|
||||
}
|
||||
const base = baseOf(data);
|
||||
if (base) {
|
||||
stamp(base.path, filled);
|
||||
}
|
||||
return deepFreeze(filled) as DocData;
|
||||
}
|
||||
|
||||
export function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every((item, index) => deepEqual(item, b[index]))
|
||||
);
|
||||
}
|
||||
if (isPlainObject(a) && isPlainObject(b)) {
|
||||
const keys = Object.keys(a);
|
||||
return (
|
||||
keys.length === Object.keys(b).length &&
|
||||
keys.every((key) => deepEqual(a[key], own(b, key)))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Freezes `value` and everything in it; a frozen object is taken as already deep-frozen. */
|
||||
export function deepFreeze<T>(value: T): T {
|
||||
if (Object.isFrozen(value)) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(deepFreeze);
|
||||
Object.freeze(value);
|
||||
} else if (isPlainObject(value)) {
|
||||
for (const key in value) {
|
||||
deepFreeze(value[key]);
|
||||
}
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isPlainObject(
|
||||
value: unknown,
|
||||
): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
export function own(object: Record<string, unknown>, key: string): unknown {
|
||||
return Object.prototype.hasOwnProperty.call(object, key)
|
||||
? object[key]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Sets an own enumerable property, defining a `__proto__` key rather than assigning it. */
|
||||
function setOwn<T>(object: Record<string, unknown>, key: string, value: T): T {
|
||||
if (key === "__proto__") {
|
||||
Object.defineProperty(object, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value,
|
||||
writable: true,
|
||||
});
|
||||
} else {
|
||||
object[key] = value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A JSON-only copy of a plain object; throws naming `what` on anything else. */
|
||||
export function jsonObject(
|
||||
value: unknown,
|
||||
what: string,
|
||||
): Record<string, unknown> {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error(`${what} must be a plain object, got ${describe(value)}.`);
|
||||
}
|
||||
return jsonValue(value, what) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A JSON-only copy of `value`; throws naming `field` on anything JSON cannot carry. */
|
||||
export function jsonValue(value: unknown, field: string): unknown {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "string"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error(
|
||||
`"${field}" is ${value}; only finite numbers can be stored.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const marker = markerOf(value);
|
||||
if (marker) {
|
||||
const { name } = MARKERS.get(marker)!;
|
||||
throw new Error(
|
||||
`"${field}" holds ${name} inside an object; markers must be top-level values, like { "${field}": ${name} }.`,
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item, index) => {
|
||||
if (item === undefined) {
|
||||
throw new Error(
|
||||
`"${field}[${index}]" is undefined; arrays cannot hold undefined.`,
|
||||
);
|
||||
}
|
||||
return jsonValue(item, `${field}[${index}]`);
|
||||
});
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
rejectClickUpAsset(value, field);
|
||||
const copy: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
if (value[key] !== undefined) {
|
||||
validateName(key, `In "${field}", the field`);
|
||||
setOwn(copy, key, jsonValue(value[key], `${field}.${key}`));
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
throw new Error(
|
||||
`"${field}" is ${describe(value)}; only JSON values (strings, numbers, booleans, null, arrays and plain objects) can be stored. Store dates as ISO strings or numbers.`,
|
||||
);
|
||||
}
|
||||
|
||||
function rejectClickUpAsset(
|
||||
value: Record<string, unknown>,
|
||||
field: string,
|
||||
): void {
|
||||
if (isClickUpAsset(value)) {
|
||||
throw new Error(
|
||||
`"${field}" is a ClickUp ${value.kind}; ClickUp data never goes into shared state, where every viewer can read it. Store its id and fetch it with @kits/clickup.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The `kind` of every ClickUp kit asset that carries what only the viewer may see. */
|
||||
const CLICKUP_KINDS = new Set([
|
||||
"space",
|
||||
"folder",
|
||||
"list",
|
||||
"status",
|
||||
"customField",
|
||||
"task",
|
||||
"taskType",
|
||||
"comment",
|
||||
"inboxBundle",
|
||||
"reminder",
|
||||
]);
|
||||
|
||||
/** Whether `value` has the shape of a ClickUp kit asset; a person is not one. */
|
||||
function isClickUpAsset(
|
||||
value: Record<string, unknown>,
|
||||
): value is Record<string, unknown> & { kind: string } {
|
||||
return (
|
||||
typeof value.kind === "string" &&
|
||||
CLICKUP_KINDS.has(value.kind) &&
|
||||
typeof value.id === "string" &&
|
||||
typeof value.name === "string" &&
|
||||
typeof value.url === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function jsonClone(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(jsonClone);
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const copy: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
setOwn(copy, key, jsonClone(value[key]));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `value` for an error message: `null`, `NaN`, `a string`, `an Array`, `a Date`. */
|
||||
export function describe(value: unknown): string {
|
||||
if (value == null || (typeof value === "number" && !Number.isFinite(value))) {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return withArticle(typeof value);
|
||||
}
|
||||
const name = (value as object).constructor?.name;
|
||||
return withArticle(name && name !== "Object" ? name : "object");
|
||||
}
|
||||
|
||||
function withArticle(noun: string): string {
|
||||
return `${/^[aeiou]/i.test(noun) ? "an" : "a"} ${noun}`;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { DocData } from "./shared";
|
||||
import { emptyDocument, withDefaults } from "./fields";
|
||||
import { childDocumentPath, collectionPath, documentPath } from "./paths";
|
||||
import type { Fields, SharedCollection, SharedDoc } from "./shared";
|
||||
import { getStore, NO_DATA } from "./store";
|
||||
|
||||
/** The document at `path` over the page's store: `data` and `exists` read the current state, the verbs write it. `data` is built once per stored snapshot; a null path gives empty data and verbs that do nothing. */
|
||||
export function docHandle(
|
||||
path: string | null | undefined,
|
||||
defaults?: Fields,
|
||||
): SharedDoc {
|
||||
const key = path == null ? null : documentPath(path);
|
||||
const store = getStore();
|
||||
let last: { stored: DocData | null; data: DocData } | undefined;
|
||||
return {
|
||||
get exists() {
|
||||
return key !== null && store.data(key) !== null;
|
||||
},
|
||||
get data() {
|
||||
const stored = key === null ? null : store.data(key);
|
||||
if (!last || last.stored !== stored) {
|
||||
const data = withDefaults(stored ?? emptyDocument(key ?? ""), defaults);
|
||||
last = { stored, data };
|
||||
}
|
||||
return last.data;
|
||||
},
|
||||
...(key === null
|
||||
? INERT_DOC
|
||||
: {
|
||||
set: (fields) => store.set(key, fields),
|
||||
update: (fields) => store.update(key, fields),
|
||||
delete: () => store.delete(key),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The collection at `path` over the page's store; `items` reads the current state. A null path gives no items and verbs that do nothing. */
|
||||
export function collectionHandle(
|
||||
path: string | null | undefined,
|
||||
): SharedCollection {
|
||||
const key = path == null ? null : collectionPath(path);
|
||||
const store = getStore();
|
||||
return {
|
||||
get items() {
|
||||
return key === null ? NO_DATA : store.items(key);
|
||||
},
|
||||
...(key === null
|
||||
? INERT_COLLECTION
|
||||
: {
|
||||
add: (fields) => store.add(key, fields),
|
||||
set: (id, fields) => {
|
||||
store.set(childDocumentPath(key, id), fields);
|
||||
return id;
|
||||
},
|
||||
update: (id, fields) =>
|
||||
store.update(childDocumentPath(key, id), fields),
|
||||
delete: (id) => store.delete(childDocumentPath(key, id)),
|
||||
move: (id, to) => store.move(key, id, to),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
const INERT_DOC = { set: noop, update: noop, delete: noop };
|
||||
const INERT_COLLECTION = {
|
||||
add: () => "",
|
||||
set: (id: string) => id,
|
||||
update: noop,
|
||||
delete: noop,
|
||||
move: noop,
|
||||
};
|
||||
@@ -0,0 +1,365 @@
|
||||
import type * as Y from "yjs";
|
||||
import {
|
||||
arrayUnion,
|
||||
canonicalKey,
|
||||
distinct,
|
||||
increment,
|
||||
isOpaqueLabels,
|
||||
markerOf,
|
||||
opaque,
|
||||
sharedText,
|
||||
type IncrementMarker,
|
||||
type Marker,
|
||||
type MarkerKey,
|
||||
type OpaqueMarker,
|
||||
type OpaqueNode,
|
||||
type OpaqueSeed,
|
||||
type OpaqueY,
|
||||
type RemoveMarker,
|
||||
type TextMarker,
|
||||
type UnionMarker,
|
||||
} from "./fields";
|
||||
import type { JsonValue } from "./shared";
|
||||
|
||||
/**
|
||||
* A field kind stored as a nested Yjs type inside a document's map and read
|
||||
* as plain JSON: a marker creates one, a marker or a plain value of the
|
||||
* kind's own shape is applied as a change to it, and a value of another shape
|
||||
* replaces it.
|
||||
*/
|
||||
export interface FieldKind {
|
||||
/** True when `value` is this kind's nested type. */
|
||||
is(value: unknown): boolean;
|
||||
read(value: unknown): unknown;
|
||||
/** True when `leaf`, a marker or a plain value, is a change this kind applies to `current`, its existing type. */
|
||||
accepts(leaf: unknown, current: unknown): boolean;
|
||||
apply(value: unknown, leaf: unknown): void;
|
||||
/** A fresh nested type from this kind's marker over `current`: a plain value, or another document's nested type. */
|
||||
create(marker: Marker, current: unknown): unknown;
|
||||
/** A plain value of this kind's shape as the marker that recreates it. */
|
||||
recreate(plain: unknown): Marker | undefined;
|
||||
}
|
||||
|
||||
/** The key inside a nested map that names its kind; never a client id or a value. */
|
||||
const TAG = "#";
|
||||
/** An opaque wrapper's keys: its labels and the Y type they describe. */
|
||||
const Y_KEY = "y";
|
||||
const OF_KEY = "of";
|
||||
const VALUE_KEY = "v";
|
||||
|
||||
/** What an opaque field holds: its labels and the Y type the owning kit drives. */
|
||||
export interface Opaque {
|
||||
readonly y: OpaqueY;
|
||||
readonly of: string;
|
||||
readonly value: Y.XmlFragment | Y.Map<unknown>;
|
||||
}
|
||||
|
||||
export class Kinds {
|
||||
/** What a stored value holds as an opaque field, or undefined for anything else. */
|
||||
readonly opaqueOf: (value: unknown) => Opaque | undefined;
|
||||
private readonly all: readonly FieldKind[];
|
||||
private readonly byMarker: ReadonlyMap<MarkerKey, FieldKind>;
|
||||
|
||||
/**
|
||||
* @param clientID This client's Yjs id, the key it owns inside every counter.
|
||||
*/
|
||||
constructor(
|
||||
private readonly yjs: typeof Y,
|
||||
clientID: number,
|
||||
) {
|
||||
const tagged = (value: unknown, tag: string) =>
|
||||
value instanceof yjs.Map && value.get(TAG) === tag;
|
||||
const sum = (map: Y.Map<unknown>) => {
|
||||
let total = 0;
|
||||
map.forEach((value, key) => {
|
||||
if (key !== TAG && typeof value === "number") {
|
||||
total += value;
|
||||
}
|
||||
});
|
||||
return total;
|
||||
};
|
||||
const bump = (map: Y.Map<unknown>, by: number) => {
|
||||
const mine = String(clientID);
|
||||
map.set(mine, (Number(map.get(mine)) || 0) + by);
|
||||
};
|
||||
type Element = [stamp: number, value: unknown];
|
||||
const isElement = (value: unknown): value is Element =>
|
||||
Array.isArray(value) && typeof value[0] === "number";
|
||||
/** A set's elements by key, in the map's own order. */
|
||||
const elements = (map: Y.Map<unknown>) => {
|
||||
const list: Array<[key: string, element: Element]> = [];
|
||||
map.forEach((value, key) => {
|
||||
if (key !== TAG && isElement(value)) {
|
||||
list.push([key, value]);
|
||||
}
|
||||
});
|
||||
return list;
|
||||
};
|
||||
/** A set's elements oldest first, ties by key. */
|
||||
const stamped = (map: Y.Map<unknown>) =>
|
||||
elements(map).sort(
|
||||
([x, [a]], [y, [b]]) => a - b || (x < y ? -1 : x > y ? 1 : 0),
|
||||
);
|
||||
/** A stamp after every element's. */
|
||||
const nextStamp = (map: Y.Map<unknown>) => {
|
||||
let stamp = Date.now();
|
||||
map.forEach((value) => {
|
||||
if (isElement(value)) {
|
||||
stamp = Math.max(stamp, value[0] + 1);
|
||||
}
|
||||
});
|
||||
return stamp;
|
||||
};
|
||||
/** Adds the values not yet present, stamped after every element there. */
|
||||
const addAll = (map: Y.Map<unknown>, values: readonly unknown[]) => {
|
||||
let stamp = nextStamp(map);
|
||||
for (const value of values) {
|
||||
const key = canonicalKey(value);
|
||||
if (!map.has(key)) {
|
||||
map.set(key, [stamp, value]);
|
||||
stamp += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
const text: FieldKind = {
|
||||
is: (value) => value instanceof yjs.Text,
|
||||
read: (value) => (value as Y.Text).toString(),
|
||||
accepts: (leaf) => typeof leaf === "string" || markerOf(leaf) === "#text",
|
||||
apply: (value, leaf) =>
|
||||
applyTextDiff(
|
||||
value as Y.Text,
|
||||
typeof leaf === "string" ? leaf : (leaf as TextMarker)["#text"],
|
||||
),
|
||||
create: (marker) => new yjs.Text((marker as TextMarker)["#text"]),
|
||||
recreate: (plain) =>
|
||||
typeof plain === "string" ? sharedText(plain) : undefined,
|
||||
};
|
||||
const unwrap = (value: unknown): Opaque | undefined => {
|
||||
if (!tagged(value, "opaque")) {
|
||||
return undefined;
|
||||
}
|
||||
const wrapper = value as Y.Map<unknown>;
|
||||
const labels = { y: wrapper.get(Y_KEY), of: wrapper.get(OF_KEY) };
|
||||
if (!isOpaqueLabels(labels)) {
|
||||
return undefined;
|
||||
}
|
||||
const held = wrapper.get(VALUE_KEY);
|
||||
const holdsY =
|
||||
labels.y === "xml"
|
||||
? held instanceof yjs.XmlFragment
|
||||
: held instanceof yjs.Map;
|
||||
return holdsY ? { ...labels, value: held as Opaque["value"] } : undefined;
|
||||
};
|
||||
/** A new Y type of `y` holding `seed`, filled before it joins the document. */
|
||||
const fresh = (y: OpaqueY, seed: OpaqueSeed | undefined) => {
|
||||
if (y === "map") {
|
||||
return new yjs.Map<unknown>(Object.entries(seed ?? {}));
|
||||
}
|
||||
const fragment = new yjs.XmlFragment();
|
||||
fragment.insert(0, ((seed ?? []) as readonly OpaqueNode[]).map(nodeOf));
|
||||
return fragment;
|
||||
};
|
||||
const nodeOf = (node: OpaqueNode): Y.XmlElement | Y.XmlText => {
|
||||
if ("text" in node) {
|
||||
const text = new yjs.XmlText();
|
||||
text.applyDelta([...node.text]);
|
||||
return text;
|
||||
}
|
||||
const element = new yjs.XmlElement(node.type);
|
||||
// Yjs types attributes as strings but stores any JSON.
|
||||
const attributes = element as Y.XmlElement<Record<string, JsonValue>>;
|
||||
for (const [name, value] of Object.entries(node.attrs ?? {})) {
|
||||
attributes.setAttribute(name, value);
|
||||
}
|
||||
element.insert(0, (node.children ?? []).map(nodeOf));
|
||||
return element;
|
||||
};
|
||||
/** Nothing written to an opaque field is a change inside it: a marker of its own labels keeps it, seed or none; anything else replaces it. */
|
||||
const opaqueKind: FieldKind = {
|
||||
is: (value) => unwrap(value) !== undefined,
|
||||
read: (value) => {
|
||||
const { y, of } = unwrap(value)!;
|
||||
return opaque(y, of);
|
||||
},
|
||||
accepts: (leaf, current) => {
|
||||
const held = unwrap(current);
|
||||
if (held === undefined || markerOf(leaf) !== "#opaque") {
|
||||
return false;
|
||||
}
|
||||
const { y, of } = (leaf as OpaqueMarker)["#opaque"];
|
||||
return held.y === y && held.of === of;
|
||||
},
|
||||
apply: () => {},
|
||||
create: (marker, current) => {
|
||||
const { y, of, seed } = (marker as OpaqueMarker)["#opaque"];
|
||||
const held = unwrap(current);
|
||||
const kept = held?.y === y && held.of === of ? held.value : undefined;
|
||||
const wrapper = new yjs.Map<unknown>();
|
||||
wrapper.set(TAG, "opaque");
|
||||
wrapper.set(Y_KEY, y);
|
||||
wrapper.set(OF_KEY, of);
|
||||
wrapper.set(VALUE_KEY, kept?.clone() ?? fresh(y, seed));
|
||||
return wrapper;
|
||||
},
|
||||
recreate: (plain) =>
|
||||
markerOf(plain) === "#opaque" ? (plain as OpaqueMarker) : undefined,
|
||||
};
|
||||
const counter: FieldKind = {
|
||||
is: (value) => tagged(value, "counter"),
|
||||
read: (value) => sum(value as Y.Map<unknown>),
|
||||
accepts: (leaf) =>
|
||||
typeof leaf === "number" || markerOf(leaf) === "#increment",
|
||||
apply: (value, leaf) => {
|
||||
const map = value as Y.Map<unknown>;
|
||||
const by =
|
||||
typeof leaf === "number"
|
||||
? leaf - sum(map)
|
||||
: (leaf as IncrementMarker)["#increment"];
|
||||
if (by !== 0) {
|
||||
bump(map, by);
|
||||
}
|
||||
},
|
||||
create: (marker, current) => {
|
||||
const map = new yjs.Map<unknown>();
|
||||
map.set(TAG, "counter");
|
||||
const start = typeof current === "number" ? current : 0;
|
||||
map.set(
|
||||
String(clientID),
|
||||
start + (marker as IncrementMarker)["#increment"],
|
||||
);
|
||||
return map;
|
||||
},
|
||||
recreate: (plain) =>
|
||||
typeof plain === "number" ? increment(plain) : undefined,
|
||||
};
|
||||
const set: FieldKind = {
|
||||
is: (value) => tagged(value, "set"),
|
||||
read: (value) =>
|
||||
stamped(value as Y.Map<unknown>).map(([, [, element]]) => element),
|
||||
accepts: (leaf) =>
|
||||
Array.isArray(leaf) ||
|
||||
markerOf(leaf) === "#union" ||
|
||||
markerOf(leaf) === "#remove",
|
||||
apply: (value, leaf) => {
|
||||
const map = value as Y.Map<unknown>;
|
||||
if (Array.isArray(leaf)) {
|
||||
const wanted = new Set(leaf.map(canonicalKey));
|
||||
for (const [key] of elements(map)) {
|
||||
if (!wanted.has(key)) {
|
||||
map.delete(key);
|
||||
}
|
||||
}
|
||||
addAll(map, leaf);
|
||||
} else if (markerOf(leaf) === "#union") {
|
||||
addAll(map, (leaf as UnionMarker)["#union"]);
|
||||
} else {
|
||||
for (const removed of (leaf as RemoveMarker)["#remove"]) {
|
||||
map.delete(canonicalKey(removed));
|
||||
}
|
||||
}
|
||||
},
|
||||
create: (marker, current) => {
|
||||
const map = new yjs.Map<unknown>();
|
||||
map.set(TAG, "set");
|
||||
const union = markerOf(marker) === "#union";
|
||||
const added = union ? (marker as UnionMarker)["#union"] : [];
|
||||
const removed = new Set(
|
||||
union ? [] : (marker as RemoveMarker)["#remove"].map(canonicalKey),
|
||||
);
|
||||
const kept = Array.isArray(current) ? current : [];
|
||||
let stamp = Date.now();
|
||||
for (const value of distinct([...kept, ...added])) {
|
||||
const key = canonicalKey(value);
|
||||
if (!removed.has(key)) {
|
||||
map.set(key, [stamp, value]);
|
||||
stamp += 1;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
},
|
||||
recreate: (plain) =>
|
||||
Array.isArray(plain)
|
||||
? arrayUnion(...(plain as JsonValue[]))
|
||||
: undefined,
|
||||
};
|
||||
this.opaqueOf = unwrap;
|
||||
this.all = [text, opaqueKind, counter, set];
|
||||
this.byMarker = new Map([
|
||||
["#text", text],
|
||||
["#opaque", opaqueKind],
|
||||
["#increment", counter],
|
||||
["#union", set],
|
||||
["#remove", set],
|
||||
]);
|
||||
}
|
||||
|
||||
/** The kind of a stored value, or undefined for a plain JSON value. */
|
||||
kindOf(value: unknown): FieldKind | undefined {
|
||||
return typeof value === "object" && value !== null
|
||||
? this.all.find((kind) => kind.is(value))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** A stored value as JSON: a kind reads as its plain value, a nested type of no known kind as its `toJSON()`. */
|
||||
read(value: unknown): unknown {
|
||||
const kind = this.kindOf(value);
|
||||
if (kind) {
|
||||
return kind.read(value);
|
||||
}
|
||||
return value instanceof this.yjs.AbstractType ? value.toJSON() : value;
|
||||
}
|
||||
|
||||
/** What to store for `leaf`: a marker becomes a fresh nested type over `current`, anything else is stored as is. */
|
||||
stored(leaf: unknown, current: unknown): unknown {
|
||||
const marker = markerOf(leaf);
|
||||
const kind = marker && this.byMarker.get(marker);
|
||||
return kind ? kind.create(leaf as Marker, current) : leaf;
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the smallest span of whole characters that turns the text into `next`; the caret bounds the common prefix so repeated characters resolve near the edit. */
|
||||
export function applyTextDiff(
|
||||
text: Y.Text,
|
||||
next: string,
|
||||
caret = next.length,
|
||||
): void {
|
||||
const current = text.toString();
|
||||
if (current === next) {
|
||||
return;
|
||||
}
|
||||
let prefix = 0;
|
||||
const maxPrefix = Math.min(current.length, next.length, Math.max(caret, 0));
|
||||
while (prefix < maxPrefix && current[prefix] === next[prefix]) {
|
||||
prefix += 1;
|
||||
}
|
||||
if (isHighSurrogate(current.charCodeAt(prefix - 1))) {
|
||||
prefix -= 1;
|
||||
}
|
||||
let suffix = 0;
|
||||
const maxSuffix = Math.min(current.length, next.length) - prefix;
|
||||
while (
|
||||
suffix < maxSuffix &&
|
||||
current[current.length - 1 - suffix] === next[next.length - 1 - suffix]
|
||||
) {
|
||||
suffix += 1;
|
||||
}
|
||||
if (isLowSurrogate(current.charCodeAt(current.length - suffix))) {
|
||||
suffix -= 1;
|
||||
}
|
||||
const deleted = current.length - prefix - suffix;
|
||||
if (deleted > 0) {
|
||||
text.delete(prefix, deleted);
|
||||
}
|
||||
const inserted = next.slice(prefix, next.length - suffix);
|
||||
if (inserted.length > 0) {
|
||||
text.insert(prefix, inserted);
|
||||
}
|
||||
}
|
||||
|
||||
function isHighSurrogate(code: number): boolean {
|
||||
return code >= 0xd800 && code <= 0xdbff;
|
||||
}
|
||||
|
||||
function isLowSurrogate(code: number): boolean {
|
||||
return code >= 0xdc00 && code <= 0xdfff;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import type * as Y from "yjs";
|
||||
import type { Room } from "../room";
|
||||
import type { DocumentSnapshot, LeafStore, Leaves } from "./fields";
|
||||
import {
|
||||
applyPatch,
|
||||
deepEqual,
|
||||
documentSnapshot,
|
||||
ORDER_KEY,
|
||||
topOf,
|
||||
} from "./fields";
|
||||
import { applyTextDiff, Kinds, type Opaque } from "./kinds";
|
||||
import { appendKey, compareOrdered, indexOf, rankOf } from "./order";
|
||||
import { parentCollectionOf } from "./paths";
|
||||
|
||||
const REGISTRY_NAME = "#d";
|
||||
const META_NAME = "#m";
|
||||
const BUILD_KEY = "build";
|
||||
const LOCAL_ORIGIN = { origin: "sync" };
|
||||
/** The origin of writes inside an opaque field, which the kit's undo does not track. */
|
||||
const OPAQUE_ORIGIN = { origin: "sync:opaque" };
|
||||
const UNDO_CAPTURE_MS = 500;
|
||||
/** Changed members a cached collection absorbs one by one before it is rebuilt whole. */
|
||||
const REBASE_LIMIT = 64;
|
||||
export const NO_ITEMS: readonly DocumentSnapshot[] = Object.freeze([]);
|
||||
|
||||
type DocumentMap = Y.Map<unknown>;
|
||||
|
||||
/**
|
||||
* One room's documents, the `#d` registry keyed by full path, read through
|
||||
* cached snapshots and written with undo over this client's own changes.
|
||||
* `onChange` fires after every transaction with the documents it touched,
|
||||
* once the undo stack reflects it.
|
||||
*/
|
||||
export class Mirror {
|
||||
readonly undo: Y.UndoManager;
|
||||
/** True once a newer build than this client's is live; it then only reads. */
|
||||
outdated = false;
|
||||
private readonly kinds: Kinds;
|
||||
private readonly registry: Y.Map<DocumentMap>;
|
||||
private readonly meta: Y.Map<unknown>;
|
||||
/** Each collection's document paths. */
|
||||
private readonly members = new Map<string, Set<string>>();
|
||||
private readonly documents = new Map<string, DocumentSnapshot | null>();
|
||||
private readonly collections = new Map<string, readonly DocumentSnapshot[]>();
|
||||
/** Per cached document behind the registry, the top-level fields that changed, or null for all of them. */
|
||||
private readonly staleFields = new Map<string, Set<string> | null>();
|
||||
/** Per cached collection behind the registry, the members that changed, each with the snapshot the collection holds for it. */
|
||||
private readonly staleMembers = new Map<
|
||||
string,
|
||||
Map<string, DocumentSnapshot | null>
|
||||
>();
|
||||
/** The documents the current transaction changed, reported once it ends. */
|
||||
private readonly touched = new Set<string>();
|
||||
private unwatchBuild: (() => void) | undefined;
|
||||
|
||||
constructor(
|
||||
readonly room: Room,
|
||||
private readonly onChange: (paths: ReadonlySet<string>) => void,
|
||||
) {
|
||||
this.kinds = new Kinds(room.Y, room.doc.clientID);
|
||||
this.registry = room.doc.getMap(REGISTRY_NAME) as Y.Map<DocumentMap>;
|
||||
this.meta = room.doc.getMap(META_NAME);
|
||||
this.undo = new room.Y.UndoManager(this.registry, {
|
||||
captureTimeout: UNDO_CAPTURE_MS,
|
||||
trackedOrigins: new Set([LOCAL_ORIGIN]),
|
||||
});
|
||||
this.registry.forEach((_, path) => this.enroll(path));
|
||||
this.registry.observeDeep(this.markStale);
|
||||
room.doc.on("afterTransaction", this.flush);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.registry.unobserveDeep(this.markStale);
|
||||
this.unwatchBuild?.();
|
||||
this.room.doc.off("afterTransaction", this.flush);
|
||||
this.undo.destroy();
|
||||
}
|
||||
|
||||
private readonly flush = () => {
|
||||
this.onChange(this.touched);
|
||||
this.touched.clear();
|
||||
};
|
||||
|
||||
private readonly markStale = (
|
||||
events: Array<Y.YEvent<Y.AbstractType<unknown>>>,
|
||||
) => {
|
||||
for (const event of events) {
|
||||
if (event.target === this.registry) {
|
||||
for (const [path, change] of event.changes.keys) {
|
||||
if (change.action === "delete") {
|
||||
this.members.get(parentCollectionOf(path))?.delete(path);
|
||||
} else {
|
||||
this.enroll(path);
|
||||
}
|
||||
this.invalidate(path, null);
|
||||
}
|
||||
} else if (typeof event.path[0] === "string") {
|
||||
// A document's own map names the changed fields; a nested type names its field.
|
||||
const [path, field] = event.path;
|
||||
this.invalidate(
|
||||
path,
|
||||
field === undefined
|
||||
? (event as Y.YMapEvent<unknown>).keysChanged
|
||||
: [String(field)],
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private enroll(path: string) {
|
||||
const collection = parentCollectionOf(path);
|
||||
const paths = this.members.get(collection) ?? new Set<string>();
|
||||
paths.add(path);
|
||||
this.members.set(collection, paths);
|
||||
}
|
||||
|
||||
/** Notes that `path` changed in the given keys' top-level fields, or in all of them for null. */
|
||||
private invalidate(path: string, keys: Iterable<string> | null) {
|
||||
this.touched.add(path);
|
||||
if (this.documents.has(path)) {
|
||||
const fields = this.staleFields.get(path);
|
||||
if (keys === null || fields === null) {
|
||||
this.staleFields.set(path, null);
|
||||
} else {
|
||||
const tops = fields ?? new Set<string>();
|
||||
for (const key of keys) {
|
||||
tops.add(topOf(key));
|
||||
}
|
||||
this.staleFields.set(path, tops);
|
||||
}
|
||||
}
|
||||
const collection = parentCollectionOf(path);
|
||||
if (this.collections.has(collection)) {
|
||||
const members =
|
||||
this.staleMembers.get(collection) ??
|
||||
new Map<string, DocumentSnapshot | null>();
|
||||
if (!members.has(path)) {
|
||||
members.set(path, this.documents.get(path) ?? null);
|
||||
}
|
||||
if (members.size > REBASE_LIMIT) {
|
||||
this.collections.delete(collection);
|
||||
this.staleMembers.delete(collection);
|
||||
} else {
|
||||
this.staleMembers.set(collection, members);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
has(path: string): boolean {
|
||||
return this.registry.has(path);
|
||||
}
|
||||
|
||||
/** The document at `path`, or null when it does not exist; unchanged fields keep their objects, and a document that still reads the same keeps its snapshot. */
|
||||
document(path: string): DocumentSnapshot | null {
|
||||
let snapshot = this.documents.get(path);
|
||||
const stale = this.staleFields.get(path);
|
||||
if (snapshot === undefined || stale !== undefined) {
|
||||
const map = this.registry.get(path);
|
||||
const previous =
|
||||
snapshot && stale ? { data: snapshot.data, changed: stale } : undefined;
|
||||
const rebuilt = map
|
||||
? documentSnapshot(
|
||||
path,
|
||||
this.fieldsOf(map, previous?.changed),
|
||||
previous,
|
||||
)
|
||||
: null;
|
||||
if (
|
||||
!snapshot ||
|
||||
rebuilt?.data !== snapshot.data ||
|
||||
rebuilt.order !== snapshot.order
|
||||
) {
|
||||
snapshot = rebuilt;
|
||||
}
|
||||
this.staleFields.delete(path);
|
||||
this.documents.set(path, snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** The direct children of `path`, in order; unchanged members keep their snapshots. */
|
||||
collection(path: string): readonly DocumentSnapshot[] {
|
||||
let items = this.collections.get(path);
|
||||
const stale = this.staleMembers.get(path);
|
||||
if (items === undefined || stale !== undefined) {
|
||||
items =
|
||||
(items && stale && this.rebased(items, stale)) ?? this.built(path);
|
||||
this.staleMembers.delete(path);
|
||||
this.collections.set(path, items);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private built(path: string): readonly DocumentSnapshot[] {
|
||||
const items: DocumentSnapshot[] = [];
|
||||
for (const member of this.members.get(path) ?? []) {
|
||||
const snapshot = this.document(member);
|
||||
if (snapshot) {
|
||||
items.push(snapshot);
|
||||
}
|
||||
}
|
||||
return items.length === 0
|
||||
? NO_ITEMS
|
||||
: Object.freeze(items.sort(compareOrdered));
|
||||
}
|
||||
|
||||
/** `items` with each changed member replaced, moved, inserted or dropped in place, `items` itself when none changed; undefined once they no longer match. */
|
||||
private rebased(
|
||||
items: readonly DocumentSnapshot[],
|
||||
members: Map<string, DocumentSnapshot | null>,
|
||||
): readonly DocumentSnapshot[] | undefined {
|
||||
let next: DocumentSnapshot[] | undefined;
|
||||
for (const [path, previous] of members) {
|
||||
const snapshot = this.document(path);
|
||||
if (snapshot === previous) {
|
||||
continue;
|
||||
}
|
||||
next ??= [...items];
|
||||
if (previous) {
|
||||
const index = indexOf(next, previous);
|
||||
if (index === -1) {
|
||||
return undefined;
|
||||
}
|
||||
if (snapshot?.order === previous.order) {
|
||||
next[index] = snapshot;
|
||||
continue;
|
||||
}
|
||||
next.splice(index, 1);
|
||||
}
|
||||
if (snapshot) {
|
||||
next.splice(rankOf(next, snapshot), 0, snapshot);
|
||||
}
|
||||
}
|
||||
if (!next) {
|
||||
return items;
|
||||
}
|
||||
return next.length === 0 ? NO_ITEMS : Object.freeze(next);
|
||||
}
|
||||
|
||||
/** The text a field holds, or undefined when it holds anything else. */
|
||||
textAt(path: string, field: string): Y.Text | undefined {
|
||||
const value = this.registry.get(path)?.get(field);
|
||||
return value instanceof this.room.Y.Text ? value : undefined;
|
||||
}
|
||||
|
||||
/** The opaque field a field holds, its labels and Y type, or undefined when it holds anything else. */
|
||||
opaqueAt(path: string, field: string): Opaque | undefined {
|
||||
return this.kinds.opaqueOf(this.registry.get(path)?.get(field));
|
||||
}
|
||||
|
||||
/** A place in a text that survives edits around it. */
|
||||
anchor(path: string, field: string, index: number): unknown {
|
||||
const text = this.textAt(path, field);
|
||||
return text && this.room.Y.createRelativePositionFromTypeIndex(text, index);
|
||||
}
|
||||
|
||||
indexOf(anchor: unknown): number | undefined {
|
||||
return this.room.Y.createAbsolutePositionFromRelativePosition(
|
||||
anchor as Y.RelativePosition,
|
||||
this.room.doc,
|
||||
)?.index;
|
||||
}
|
||||
|
||||
/** Runs `change` as one transaction, undoable where it touches documents. */
|
||||
transact(change: () => void): void {
|
||||
this.room.doc.transact(change, LOCAL_ORIGIN);
|
||||
}
|
||||
|
||||
/** Runs `change` as one transaction the kit's undo ignores. */
|
||||
transactOpaque(change: () => void): void {
|
||||
this.room.doc.transact(change, OPAQUE_ORIGIN);
|
||||
}
|
||||
|
||||
/** Writes `fields` into the existing document's map as `update()` would and removes every field it does not name; an equal set writes nothing. A missing document is created, a plain value taking the kind `kindsLike` holds for that field. */
|
||||
setDocument(path: string, fields: Leaves, kindsLike?: string): void {
|
||||
const existing = this.registry.get(path);
|
||||
if (!existing) {
|
||||
fields.set(ORDER_KEY, this.orderOf(path));
|
||||
const kinds = kindsLike ? this.registry.get(kindsLike) : undefined;
|
||||
this.registry.set(path, this.mapOf(fields, kinds));
|
||||
return;
|
||||
}
|
||||
const leaves = this.leavesOf(existing);
|
||||
for (const key of [...existing.keys()]) {
|
||||
if (!key.startsWith("#") && !fields.has(key)) {
|
||||
leaves.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, leaf] of fields) {
|
||||
leaves.set(key, leaf);
|
||||
}
|
||||
}
|
||||
|
||||
/** Merges into an existing document, touching only the fields the patch names; a patch that cannot apply writes nothing. */
|
||||
updateDocument(path: string, patch: Leaves): void {
|
||||
const map = this.registry.get(path);
|
||||
if (map) {
|
||||
applyPatch(this.fieldsOf(map), patch);
|
||||
applyPatch(this.leavesOf(map), patch);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes the document and every descendant document. */
|
||||
deleteDocument(path: string): void {
|
||||
const doomed = [path];
|
||||
const under = `${path}/`;
|
||||
for (const [collection, paths] of this.members) {
|
||||
if (collection.startsWith(under)) {
|
||||
doomed.push(...paths);
|
||||
}
|
||||
}
|
||||
for (const key of doomed) {
|
||||
this.registry.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
setOrder(path: string, order: string): void {
|
||||
this.registry.get(path)?.set(ORDER_KEY, order);
|
||||
}
|
||||
|
||||
/** Applies one edit as the minimal diff; nothing when the field holds no text. */
|
||||
editText(path: string, field: string, next: string, caret: number): void {
|
||||
const text = this.textAt(path, field);
|
||||
if (text) {
|
||||
applyTextDiff(text, next, caret);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps `#m.build` at the newest live build, when this client may write; `outdated` once a build above `build` is live. Watches once. */
|
||||
watchBuild(build: number): void {
|
||||
if (this.unwatchBuild) {
|
||||
return;
|
||||
}
|
||||
const reconcile = () => {
|
||||
const live = Number(this.meta.get(BUILD_KEY) ?? 0);
|
||||
if (live > build) {
|
||||
this.outdated = true;
|
||||
} else if (live < build && !this.room.status.readOnly) {
|
||||
this.transact(() => this.meta.set(BUILD_KEY, build));
|
||||
}
|
||||
};
|
||||
this.meta.observe(reconcile);
|
||||
this.unwatchBuild = () => this.meta.unobserve(reconcile);
|
||||
reconcile();
|
||||
}
|
||||
|
||||
/** The document's current order key, or one after the last item of its collection. */
|
||||
private orderOf(path: string): string {
|
||||
const current = this.registry.get(path)?.get(ORDER_KEY);
|
||||
return typeof current === "string"
|
||||
? current
|
||||
: appendKey(this.collection(parentCollectionOf(path)));
|
||||
}
|
||||
|
||||
/** The live map as a patch's target: reads are JSON; a write a field's kind accepts is applied to it, an equal write is skipped, anything else replaces the field. */
|
||||
private leavesOf(map: DocumentMap): LeafStore {
|
||||
const { kinds } = this;
|
||||
return {
|
||||
keys: () => map.keys(),
|
||||
has: (key) => map.has(key),
|
||||
get: (key) => kinds.read(map.get(key)),
|
||||
set: (key, leaf) => {
|
||||
const current = map.get(key);
|
||||
const kind = kinds.kindOf(current);
|
||||
if (kind?.accepts(leaf, current)) {
|
||||
kind.apply(current, leaf);
|
||||
} else if (!deepEqual(kinds.read(current), leaf)) {
|
||||
map.set(key, kinds.stored(leaf, kinds.read(current)));
|
||||
}
|
||||
},
|
||||
delete: (key) => map.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
/** A map's entries as JSON, every nested type read as its plain value; with `tops`, only the `#` keys and the leaves under those top-level fields. */
|
||||
private fieldsOf(map: DocumentMap, tops?: ReadonlySet<string>): Leaves {
|
||||
const fields: Leaves = new Map();
|
||||
map.forEach((value, key) => {
|
||||
if (!tops || key.startsWith("#") || tops.has(topOf(key))) {
|
||||
fields.set(key, this.kinds.read(value));
|
||||
}
|
||||
});
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** A new document's map of `fields`; a plain value for a field `kinds`, the document it was spread from, holds as a nested type recreates that kind over a copy of the type there. */
|
||||
private mapOf(fields: Leaves, kinds?: DocumentMap): DocumentMap {
|
||||
const map = new this.room.Y.Map<unknown>();
|
||||
for (const [key, value] of fields) {
|
||||
const current = kinds?.get(key);
|
||||
const kind = this.kinds.kindOf(current);
|
||||
const leaf = kind?.recreate(value) ?? value;
|
||||
map.set(key, this.kinds.stored(leaf, kind ? current : undefined));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { describe, isPlainObject } from "./fields";
|
||||
import { storageKey } from "./paths";
|
||||
import { useStable } from "./useStoreValue";
|
||||
import { warnOnce } from "./warn";
|
||||
|
||||
const VALUE_LIMIT = 4000;
|
||||
|
||||
interface HostStorage {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<string>;
|
||||
remove(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** A stored value of mine outside React: `get()`, `set()` and `remove()`. */
|
||||
export interface MyState<T> {
|
||||
/** The stored value, or `initial` while nothing is stored. */
|
||||
get(): T;
|
||||
/** Replaces the value; a function receives the current one, like a `useState` setter. */
|
||||
set(next: T | ((current: T) => T)): void;
|
||||
/** Deletes the key; `get()` returns `initial` again. */
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
/** What the host had at the first read; undefined until that read lands. */
|
||||
stored?: string | null;
|
||||
/** What this page wrote since; undefined while nothing was written. */
|
||||
local?: string | null;
|
||||
read?: Promise<void>;
|
||||
parsed?: { raw: string; initial: unknown; value: unknown };
|
||||
listeners: Set<() => void>;
|
||||
}
|
||||
|
||||
const entries = new Map<string, Entry>();
|
||||
|
||||
function host(): HostStorage | undefined {
|
||||
return (globalThis as { clickup?: { storage?: HostStorage } }).clickup
|
||||
?.storage;
|
||||
}
|
||||
|
||||
/** The entry for `key`, its first read under way; without a host the read is over before it starts. */
|
||||
function entryOf(key: string): Entry {
|
||||
let entry = entries.get(key);
|
||||
if (!entry) {
|
||||
entry = { listeners: new Set() };
|
||||
entries.set(key, entry);
|
||||
const storage = host();
|
||||
if (storage) {
|
||||
const landed = (raw: string | null) => {
|
||||
entry!.stored = raw;
|
||||
entry!.listeners.forEach((listener) => listener());
|
||||
};
|
||||
entry.read = storage.get(key).then(landed, () => landed(null));
|
||||
} else {
|
||||
warnOnce(
|
||||
"myState",
|
||||
"clickup.storage is unavailable here, so useMyState keeps values for this tab only.",
|
||||
);
|
||||
entry.stored = null;
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The stored JSON as a `T`, or `initial` when it does not parse or has another shape than `initial`. */
|
||||
function parse<T>(label: string, raw: string, initial: T): T {
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw);
|
||||
if (
|
||||
initial == null ||
|
||||
(typeof value === typeof initial &&
|
||||
Array.isArray(value) === Array.isArray(initial))
|
||||
) {
|
||||
return value as T;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON.
|
||||
}
|
||||
warnOnce(
|
||||
`myState:${label}`,
|
||||
`${label} found a stored value of another shape and uses initial instead.`,
|
||||
);
|
||||
return initial;
|
||||
}
|
||||
|
||||
/** The value at `path`, a key already checked; `label` names it in warnings and `setter` in `set` errors. */
|
||||
function handle<T>(label: string, path: string, initial: T, setter = label) {
|
||||
const entry = entryOf(path);
|
||||
const notify = () => entry.listeners.forEach((listener) => listener());
|
||||
const get = (): T => {
|
||||
const raw = entry.local === undefined ? entry.stored : entry.local;
|
||||
if (raw == null) {
|
||||
return initial;
|
||||
}
|
||||
const cached = entry.parsed;
|
||||
if (cached && cached.raw === raw && cached.initial === initial) {
|
||||
return cached.value as T;
|
||||
}
|
||||
const value = parse(label, raw, initial);
|
||||
entry.parsed = { raw, initial, value };
|
||||
return value;
|
||||
};
|
||||
/** Applies `raw` here at once and sends it to the host; a refusal rolls back what this write changed, and says so. */
|
||||
const apply = (
|
||||
raw: string | null,
|
||||
send: (storage: HostStorage) => Promise<unknown>,
|
||||
) => {
|
||||
const previous = entry.local;
|
||||
entry.local = raw;
|
||||
notify();
|
||||
const storage = host();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
send(storage).catch((error: unknown) => {
|
||||
if (entry.local !== raw) {
|
||||
return;
|
||||
}
|
||||
entry.local = previous;
|
||||
notify();
|
||||
console.error(
|
||||
`[sync] ${label}: the host refused the write (${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}); ${raw?.length ?? 0} chars rolled back.`,
|
||||
);
|
||||
});
|
||||
};
|
||||
return {
|
||||
get,
|
||||
set(next: T | ((current: T) => T)) {
|
||||
const value =
|
||||
typeof next === "function" ? (next as (current: T) => T)(get()) : next;
|
||||
assertJson(value, setter);
|
||||
const json = JSON.stringify(value);
|
||||
if (json.length > VALUE_LIMIT) {
|
||||
throw new Error(
|
||||
`${setter}: ${json.length.toLocaleString("en-US")} chars, limit ${VALUE_LIMIT.toLocaleString("en-US")}. Store less or split keys.`,
|
||||
);
|
||||
}
|
||||
apply(json, (storage) => storage.set(path, json));
|
||||
},
|
||||
remove: () => apply(null, (storage) => storage.remove(path)),
|
||||
loaded: () => entry.read ?? Promise.resolve(),
|
||||
suspend() {
|
||||
if (entry.stored === undefined && entry.local === undefined) {
|
||||
throw entry.read;
|
||||
}
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
entry.listeners.add(listener);
|
||||
return () => {
|
||||
entry.listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Throws for anything JSON cannot carry. */
|
||||
function assertJson(value: unknown, at: string): void {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => assertJson(item, at));
|
||||
} else if (isPlainObject(value)) {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (value[key] !== undefined) {
|
||||
assertJson(value[key], at);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
!(
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value))
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`${at}: the value must be JSON, not ${describe(value)}. Store dates as ISO strings or numbers.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A value of mine that survives a reload, `useState` with a key:
|
||||
* `const [tab, setTab] = useMyState("tab", "board")`, stored per viewer per
|
||||
* artifact as JSON, up to 4,000 characters per key (`set` throws past that).
|
||||
* Suspends until the stored value has loaded, so the first render never shows
|
||||
* `initial` over a stored value; a key mounted later needs its own
|
||||
* `<Suspense>`.
|
||||
*/
|
||||
export function useMyState<T>(
|
||||
key: string,
|
||||
initial: T,
|
||||
): [T, (next: T | ((current: T) => T)) => void] {
|
||||
const stableInitial = useStable(initial);
|
||||
const state = useMemo(() => {
|
||||
const label = `useMyState(${JSON.stringify(key)})`;
|
||||
return handle(label, storageKey(key, label), stableInitial);
|
||||
}, [key, stableInitial]);
|
||||
state.suspend();
|
||||
return [
|
||||
useSyncExternalStore(state.subscribe, state.get, state.get),
|
||||
state.set,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The same value outside React, always current: `myState("tab", "board").get()`
|
||||
* in a draw loop or event listener, `.set(v)` to write it, `.remove()` to
|
||||
* delete the key. Starts loading the value when called.
|
||||
*/
|
||||
export function myState<T>(key: string, initial: T): MyState<T>;
|
||||
export function myState<T>(key: string): MyState<T | undefined>;
|
||||
export function myState<T>(key: string, initial?: T): MyState<T | undefined> {
|
||||
const label = `myState(${JSON.stringify(key)})`;
|
||||
return handle(label, storageKey(key, label), initial, `${label}.set`);
|
||||
}
|
||||
|
||||
/** A value the kit keeps for this viewer under a `#` key, which authors are refused; resolves once the stored value has loaded. */
|
||||
export function kitState<T>(
|
||||
key: `#${string}`,
|
||||
initial: T,
|
||||
): Promise<MyState<T>> {
|
||||
const state = handle(`kit(${JSON.stringify(key)})`, key, initial);
|
||||
return state.loaded().then(() => state);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from "react";
|
||||
import type * as Y from "yjs";
|
||||
import type { OpaqueY } from "./fields";
|
||||
import { documentPath, validateName } from "./paths";
|
||||
import { getStore, type OpaqueField, STATUS_KEY } from "./store";
|
||||
import { useStoreValue } from "./useStoreValue";
|
||||
|
||||
export type { OpaqueField } from "./store";
|
||||
|
||||
/** The raw field for the kit driving an `opaque(y, of)` field; artifacts use that kit's component, `Editor` from `@kits/components/editor` say. */
|
||||
export function opaqueField(
|
||||
doc: string,
|
||||
field: string,
|
||||
y: "xml",
|
||||
of: string,
|
||||
): OpaqueField<Y.XmlFragment>;
|
||||
export function opaqueField(
|
||||
doc: string,
|
||||
field: string,
|
||||
y: "map",
|
||||
of: string,
|
||||
): OpaqueField<Y.Map<unknown>>;
|
||||
export function opaqueField(
|
||||
doc: string,
|
||||
field: string,
|
||||
y: OpaqueY,
|
||||
of: string,
|
||||
): OpaqueField<unknown> {
|
||||
validateName(field, "A field name");
|
||||
return getStore().opaque(documentPath(doc), field, y, of);
|
||||
}
|
||||
|
||||
/** `opaqueField()` in render; suspends until shared state has loaded. */
|
||||
export function useOpaqueField(
|
||||
doc: string,
|
||||
field: string,
|
||||
y: "xml",
|
||||
of: string,
|
||||
): OpaqueField<Y.XmlFragment>;
|
||||
export function useOpaqueField(
|
||||
doc: string,
|
||||
field: string,
|
||||
y: "map",
|
||||
of: string,
|
||||
): OpaqueField<Y.Map<unknown>>;
|
||||
export function useOpaqueField(
|
||||
doc: string,
|
||||
field: string,
|
||||
y: OpaqueY,
|
||||
of: string,
|
||||
): OpaqueField<unknown> {
|
||||
const store = getStore();
|
||||
const path = documentPath(doc);
|
||||
validateName(field, "A field name");
|
||||
store.suspendUntilSynced();
|
||||
const handle = useMemo(
|
||||
() => store.opaque(path, field, y, of),
|
||||
[store, path, field, y, of],
|
||||
);
|
||||
const value = useStoreValue(path, () => handle.value);
|
||||
const holds = useStoreValue(path, () => handle.holds);
|
||||
const canEdit = useStoreValue(STATUS_KEY, () => handle.canEdit);
|
||||
return {
|
||||
value,
|
||||
holds,
|
||||
canEdit,
|
||||
awareness: handle.awareness,
|
||||
write: handle.write,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { generateKeyBetween, generateNKeysBetween } from "fractional-indexing";
|
||||
|
||||
export interface Ordered {
|
||||
readonly id: string;
|
||||
readonly order: string;
|
||||
}
|
||||
|
||||
export function compareOrdered(a: Ordered, b: Ordered): number {
|
||||
if (a.order !== b.order) {
|
||||
return a.order < b.order ? -1 : 1;
|
||||
}
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Where `item` sorts into `sorted`: its own index when present, else its insertion index. */
|
||||
export function rankOf(sorted: readonly Ordered[], item: Ordered): number {
|
||||
let low = 0;
|
||||
let high = sorted.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (compareOrdered(sorted[middle]!, item) < 0) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
/** A key after every item in `sorted`. */
|
||||
export function appendKey(sorted: readonly Ordered[]): string {
|
||||
return (
|
||||
between(sorted[sorted.length - 1]?.order ?? null, null) ??
|
||||
generateKeyBetween(null, null)
|
||||
);
|
||||
}
|
||||
|
||||
/** Keys that put `sorted[from]` at `index` among the other items; `repairs` re-key those only when no key fits between its new neighbours. */
|
||||
export function placeAt(
|
||||
sorted: readonly Ordered[],
|
||||
from: number,
|
||||
index: number,
|
||||
): { key: string; repairs: Array<{ id: string; order: string }> } {
|
||||
const other = (at: number) => sorted[at < from ? at : at + 1];
|
||||
const key = between(
|
||||
other(index - 1)?.order ?? null,
|
||||
other(index)?.order ?? null,
|
||||
);
|
||||
if (key !== undefined) {
|
||||
return { key, repairs: [] };
|
||||
}
|
||||
const keys = generateNKeysBetween(null, null, sorted.length);
|
||||
return {
|
||||
key: keys[index]!,
|
||||
repairs: sorted
|
||||
.filter((_, at) => at !== from)
|
||||
.map((item, at) => ({
|
||||
id: item.id,
|
||||
order: keys[at < index ? at : at + 1]!,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The index of `item` in `sorted`, or -1. */
|
||||
export function indexOf(sorted: readonly Ordered[], item: Ordered): number {
|
||||
const rank = rankOf(sorted, item);
|
||||
return sorted[rank] === item ? rank : -1;
|
||||
}
|
||||
|
||||
function between(a: string | null, b: string | null): string | undefined {
|
||||
try {
|
||||
return generateKeyBetween(a, b);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
const RESERVED = /[./#]/;
|
||||
|
||||
/** What a missing value stringifies to when a name or path is built from it. */
|
||||
const MISSING = /^(?:undefined|null|NaN|\[object Object\])$/;
|
||||
const MISSING_SEGMENT =
|
||||
/(?:^|\/)(?:undefined|null|NaN|\[object Object\])(?:\/|$)/;
|
||||
|
||||
export function validateName(name: unknown, what: string): string {
|
||||
if (typeof name !== "string" || name.length === 0) {
|
||||
throw new Error(`${what} must be a non-empty string.`);
|
||||
}
|
||||
if (RESERVED.test(name)) {
|
||||
throw new Error(`${what} "${name}" may not contain ".", "/" or "#".`);
|
||||
}
|
||||
if (MISSING.test(name)) {
|
||||
throw new Error(
|
||||
`${what} is "${name}", a missing value; check the variable it was built from.`,
|
||||
);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** The path's segments, each validated; leading and trailing slashes are ignored. */
|
||||
function segmentsOf(path: string, what: string): string[] {
|
||||
if (typeof path !== "string") {
|
||||
throw new Error(`${what} must be a string like "cards/k3f9".`);
|
||||
}
|
||||
const segments = path.split("/").filter((segment) => segment.length > 0);
|
||||
if (segments.length === 0) {
|
||||
throw new Error(`${what} must not be empty.`);
|
||||
}
|
||||
for (const segment of segments) {
|
||||
if (RESERVED.test(segment)) {
|
||||
throw new Error(
|
||||
`${what} "${path}" has a segment containing "." or "#"; segments may only be separated by "/".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function withoutMissing(path: string, what: string): string {
|
||||
if (MISSING_SEGMENT.test(path)) {
|
||||
throw new Error(
|
||||
`${what} "${path}" has a missing value in it; pass null as the path until the id is known.`,
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Already-normalized paths with an even, or an odd, number of segments; anything else takes the slow path, for its error. */
|
||||
const DOCUMENT = /^[^./#]+\/[^./#]+(?:\/[^./#]+\/[^./#]+)*$/;
|
||||
const COLLECTION = /^[^./#]+(?:\/[^./#]+\/[^./#]+)*$/;
|
||||
|
||||
/** A normalized document path: an even number of segments. */
|
||||
export function documentPath(path: string): string {
|
||||
const what = "A document path";
|
||||
if (DOCUMENT.test(path)) {
|
||||
return withoutMissing(path, what);
|
||||
}
|
||||
const segments = segmentsOf(path, what);
|
||||
if (segments.length % 2 !== 0) {
|
||||
throw new Error(
|
||||
`"${path}" is a collection path (${segments.length} segments); a document path has an even number of segments, like "cards/k3f9" or "game/state".`,
|
||||
);
|
||||
}
|
||||
return withoutMissing(segments.join("/"), what);
|
||||
}
|
||||
|
||||
/** A normalized collection path: an odd number of segments. */
|
||||
export function collectionPath(path: string): string {
|
||||
const what = "A collection path";
|
||||
if (COLLECTION.test(path)) {
|
||||
return withoutMissing(path, what);
|
||||
}
|
||||
const segments = segmentsOf(path, what);
|
||||
if (segments.length % 2 !== 1) {
|
||||
throw new Error(
|
||||
`"${path}" is a document path (${segments.length} segments); a collection path has an odd number of segments, like "cards" or "cards/k3f9/steps".`,
|
||||
);
|
||||
}
|
||||
return withoutMissing(segments.join("/"), what);
|
||||
}
|
||||
|
||||
/** A normalized `useMyState` key: a path of any length that does not start with `#`; `what` names the call that gets the error. */
|
||||
export function storageKey(key: string, what: string): string {
|
||||
if (typeof key === "string" && key.startsWith("#")) {
|
||||
throw new Error(
|
||||
`${what}: keys starting with "#" are kit internals. Pick a name like "draft" or "cards/42".`,
|
||||
);
|
||||
}
|
||||
return segmentsOf(key, `${what} key`).join("/");
|
||||
}
|
||||
|
||||
export function childDocumentPath(collection: string, id: string): string {
|
||||
return `${collection}/${validateName(id, "An id")}`;
|
||||
}
|
||||
|
||||
export function idOfDocument(docPath: string): string {
|
||||
return docPath.slice(docPath.lastIndexOf("/") + 1);
|
||||
}
|
||||
|
||||
export function parentCollectionOf(docPath: string): string {
|
||||
return docPath.slice(0, docPath.lastIndexOf("/"));
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
import type { Room } from "../room";
|
||||
import type { User } from "../user";
|
||||
import type { BroadcastListener, WireEvent } from "./broadcasts";
|
||||
import { Broadcasts } from "./broadcasts";
|
||||
import { deepEqual, deepFreeze, isPlainObject, jsonObject } from "./fields";
|
||||
import type { Collaborator, JsonObject, JsonValue } from "./shared";
|
||||
|
||||
const EMPTY: JsonObject = Object.freeze({});
|
||||
const NO_COLLABORATORS: readonly Collaborator[] = Object.freeze([]);
|
||||
const FRAME_MS = 16;
|
||||
|
||||
interface WireState {
|
||||
user?: unknown;
|
||||
presence?: unknown;
|
||||
clock?: number;
|
||||
events?: WireEvent[];
|
||||
}
|
||||
|
||||
export type PresenceUpdate = JsonObject | ((current: JsonObject) => JsonObject);
|
||||
|
||||
export interface Me extends Collaborator {
|
||||
setPresence(update: PresenceUpdate): void;
|
||||
}
|
||||
|
||||
interface AwarenessChange {
|
||||
added: number[];
|
||||
updated: number[];
|
||||
removed: number[];
|
||||
}
|
||||
|
||||
interface Rank {
|
||||
clock: number;
|
||||
clientId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence and broadcasts over the room's awareness, whose wire state is
|
||||
* `{ user, presence, clock, events }`; states collapse by `user.id`, greatest
|
||||
* clock then greatest client id, so one person's tabs show as one entry on
|
||||
* every page. Both directions run at most once per frame, while `me.presence`
|
||||
* applies at once and a landing state's broadcasts are delivered at once.
|
||||
*/
|
||||
export class PresenceStore {
|
||||
me: Me;
|
||||
collaborators: readonly Collaborator[] = NO_COLLABORATORS;
|
||||
private awareness: Awareness | undefined;
|
||||
private detach: (() => void) | undefined;
|
||||
/** Each shown client's collaborator with the wire state it was built from. */
|
||||
private entries = new Map<
|
||||
number,
|
||||
{ wire: WireState; collaborator: Collaborator }
|
||||
>();
|
||||
private readonly broadcasts = new Broadcasts();
|
||||
/** The clock I published last. */
|
||||
private clock = 0;
|
||||
private readonly publish = perFrame(() => this.publishNow());
|
||||
private readonly recompute = perFrame(() => this.recomputeNow());
|
||||
|
||||
constructor(
|
||||
viewer: User,
|
||||
private readonly onChange: () => void,
|
||||
) {
|
||||
this.me = Object.freeze({
|
||||
user: viewer,
|
||||
presence: EMPTY,
|
||||
setPresence: this.setPresence,
|
||||
});
|
||||
window.addEventListener("pagehide", () => {
|
||||
this.publish.cancel();
|
||||
this.awareness?.setLocalState(null);
|
||||
});
|
||||
}
|
||||
|
||||
bind(room: Room | undefined): void {
|
||||
this.detach?.();
|
||||
this.detach = undefined;
|
||||
this.publish.cancel();
|
||||
this.recompute.cancel();
|
||||
this.broadcasts.reset();
|
||||
this.awareness = room?.awareness;
|
||||
const awareness = this.awareness;
|
||||
if (!awareness) {
|
||||
this.setCollaborators(NO_COLLABORATORS);
|
||||
return;
|
||||
}
|
||||
for (const [clientId, wire] of wires(awareness)) {
|
||||
this.broadcasts.arrived(clientId, wire.events);
|
||||
}
|
||||
const onOthers = (change: AwarenessChange, origin: unknown) => {
|
||||
if (origin !== "local") {
|
||||
this.recompute();
|
||||
this.deliver(change, awareness);
|
||||
}
|
||||
};
|
||||
awareness.on("change", onOthers);
|
||||
this.detach = () => awareness.off("change", onOthers);
|
||||
if (Object.keys(this.me.presence).length > 0) {
|
||||
this.publish();
|
||||
}
|
||||
this.recompute();
|
||||
}
|
||||
|
||||
/** Sends `name` with `payload` to everyone else in the room, with the next publish. */
|
||||
broadcast(name: string, payload?: JsonValue): void {
|
||||
this.broadcasts.queue(name, payload);
|
||||
this.publish();
|
||||
}
|
||||
|
||||
onBroadcast(name: string, listener: BroadcastListener): () => void {
|
||||
return this.broadcasts.on(name, listener);
|
||||
}
|
||||
|
||||
/** Hands the broadcasts of each landed state to their listeners, as the states came. */
|
||||
private deliver(
|
||||
{ added, updated, removed }: AwarenessChange,
|
||||
awareness: Awareness,
|
||||
) {
|
||||
const states = wires(awareness);
|
||||
for (const clientId of added) {
|
||||
this.broadcasts.arrived(clientId, states.get(clientId)?.events);
|
||||
}
|
||||
for (const clientId of updated) {
|
||||
const wire = states.get(clientId);
|
||||
const id = userId(wire?.user);
|
||||
if (wire?.events && id !== undefined) {
|
||||
this.broadcasts.deliver(clientId, wire.events, userOf(wire.user, id));
|
||||
}
|
||||
}
|
||||
for (const clientId of removed) {
|
||||
this.broadcasts.forget(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly setPresence = (update: PresenceUpdate): void => {
|
||||
const next = jsonObject(
|
||||
typeof update === "function" ? update(this.me.presence) : update,
|
||||
"Presence",
|
||||
);
|
||||
if (deepEqual(this.me.presence, next)) {
|
||||
return;
|
||||
}
|
||||
this.me = Object.freeze({
|
||||
...this.me,
|
||||
presence: deepFreeze(next) as JsonObject,
|
||||
});
|
||||
this.publish();
|
||||
this.onChange();
|
||||
};
|
||||
|
||||
/** Publishes my presence and the broadcasts since the last publish, with a clock above every state of this person; without a room the broadcasts are dropped. */
|
||||
private publishNow() {
|
||||
const events = this.broadcasts.drain();
|
||||
const awareness = this.awareness;
|
||||
if (!awareness) {
|
||||
return;
|
||||
}
|
||||
const local = (awareness.getLocalState() ?? {}) as WireState;
|
||||
const user = local.user ?? this.me.user;
|
||||
const myId = myUserId(user, awareness.clientID);
|
||||
let clock = this.clock;
|
||||
for (const wire of wires(awareness).values()) {
|
||||
if (userId(wire.user) === myId) {
|
||||
clock = Math.max(clock, wire.clock ?? 0);
|
||||
}
|
||||
}
|
||||
this.clock = clock + 1;
|
||||
awareness.setLocalState({
|
||||
...local,
|
||||
user,
|
||||
presence: this.me.presence,
|
||||
clock: this.clock,
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
private recomputeNow() {
|
||||
const awareness = this.awareness;
|
||||
if (!awareness) {
|
||||
return;
|
||||
}
|
||||
const local = (awareness.getLocalState() ?? {}) as WireState;
|
||||
const myId = myUserId(local.user ?? this.me.user, awareness.clientID);
|
||||
const best = new Map<
|
||||
string,
|
||||
{ clock: number; clientId: number; wire: WireState }
|
||||
>();
|
||||
for (const [clientId, wire] of wires(awareness)) {
|
||||
const id = userId(wire.user);
|
||||
if (id === undefined || id === myId) {
|
||||
continue;
|
||||
}
|
||||
const clock = wire.clock ?? 0;
|
||||
const current = best.get(id);
|
||||
if (!current || outranks({ clock, clientId }, current)) {
|
||||
best.set(id, { clock, clientId, wire });
|
||||
}
|
||||
}
|
||||
const entries = new Map<
|
||||
number,
|
||||
{ wire: WireState; collaborator: Collaborator }
|
||||
>();
|
||||
const collaborators = [...best]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([id, { clientId, wire }]) => {
|
||||
const kept = this.entries.get(clientId);
|
||||
const collaborator =
|
||||
kept?.wire === wire
|
||||
? kept.collaborator
|
||||
: deepFreeze({
|
||||
user: userOf(wire.user, id),
|
||||
presence: isPlainObject(wire.presence)
|
||||
? (wire.presence as JsonObject)
|
||||
: EMPTY,
|
||||
});
|
||||
entries.set(clientId, { wire, collaborator });
|
||||
return collaborator;
|
||||
});
|
||||
this.entries = entries;
|
||||
this.setCollaborators(Object.freeze(collaborators));
|
||||
}
|
||||
|
||||
private setCollaborators(collaborators: readonly Collaborator[]) {
|
||||
if (!deepEqual(this.collaborators, collaborators)) {
|
||||
this.collaborators = collaborators;
|
||||
this.onChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Throttled {
|
||||
(): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/** Runs `run` at most once per frame: at the end of the current job when none ran this frame, else once at the frame's end. */
|
||||
function perFrame(run: () => void): Throttled {
|
||||
let frame: ReturnType<typeof setTimeout> | undefined;
|
||||
let queued = false;
|
||||
let again = false;
|
||||
const flush = () => {
|
||||
if (!queued) {
|
||||
return;
|
||||
}
|
||||
queued = false;
|
||||
run();
|
||||
frame = setTimeout(() => {
|
||||
frame = undefined;
|
||||
if (again) {
|
||||
again = false;
|
||||
call();
|
||||
}
|
||||
}, FRAME_MS);
|
||||
};
|
||||
const call = () => {
|
||||
if (frame !== undefined) {
|
||||
again = true;
|
||||
} else if (!queued) {
|
||||
queued = true;
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
};
|
||||
return Object.assign(call, {
|
||||
cancel() {
|
||||
clearTimeout(frame);
|
||||
frame = undefined;
|
||||
queued = false;
|
||||
again = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether state `a` is shown for a person over `b`. */
|
||||
function outranks(a: Rank, b: Rank): boolean {
|
||||
return a.clock > b.clock || (a.clock === b.clock && a.clientId > b.clientId);
|
||||
}
|
||||
|
||||
/** The room's states by client, as the kit reads them; awareness holds no null state. */
|
||||
function wires(awareness: Awareness): Map<number, WireState> {
|
||||
return awareness.getStates() as Map<number, WireState>;
|
||||
}
|
||||
|
||||
/** The id a wire state's user carries, or undefined when it names nobody. */
|
||||
function userId(user: unknown): string | undefined {
|
||||
const id = isPlainObject(user) ? user.id : undefined;
|
||||
return id == null || id === "" ? undefined : String(id);
|
||||
}
|
||||
|
||||
/** My id: the user's, or one of my client's when the host names nobody. */
|
||||
function myUserId(user: unknown, clientId: number): string {
|
||||
return userId(user) ?? `client:${clientId}`;
|
||||
}
|
||||
|
||||
function userOf(user: unknown, id: string): User {
|
||||
const seeded = isPlainObject(user) ? user : {};
|
||||
const name = seeded.name ?? seeded.displayName;
|
||||
const image = seeded.image ?? seeded.avatarUrl;
|
||||
return {
|
||||
kind: "user",
|
||||
id,
|
||||
name: typeof name === "string" ? name : "",
|
||||
url: typeof seeded.url === "string" ? seeded.url : "",
|
||||
...(typeof image === "string" && image ? { image } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { User } from "../user";
|
||||
import {
|
||||
arrayRemove as removeMarker,
|
||||
arrayUnion as unionMarker,
|
||||
deepEqual,
|
||||
deleteField as deleteMarker,
|
||||
increment as incrementMarker,
|
||||
sharedText as textMarker,
|
||||
} from "./fields";
|
||||
import { collectionHandle, docHandle } from "./handles";
|
||||
import { collectionPath, documentPath } from "./paths";
|
||||
import {
|
||||
ANY_KEY,
|
||||
getStore,
|
||||
HISTORY_KEY,
|
||||
PRESENCE_KEY,
|
||||
STATUS_KEY,
|
||||
} from "./store";
|
||||
import { useStable, useStoreValue } from "./useStoreValue";
|
||||
|
||||
export type { MyState } from "./myState";
|
||||
export { myState, useMyState } from "./myState";
|
||||
|
||||
/** What a field can hold. Store dates as ISO strings or numbers. */
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JsonValue[]
|
||||
| JsonObject;
|
||||
|
||||
export interface JsonObject {
|
||||
[field: string]: JsonValue;
|
||||
}
|
||||
|
||||
/** A document's fields plus its `id`, the last path segment; read-only. */
|
||||
export interface DocData extends JsonObject {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type FieldValue =
|
||||
| { "#delete": true }
|
||||
| { "#text": string }
|
||||
| { "#opaque": { y: "xml" | "map"; of: string } }
|
||||
| { "#increment": number }
|
||||
| { "#union": JsonValue[] }
|
||||
| { "#remove": JsonValue[] };
|
||||
|
||||
/**
|
||||
* The fields to write, each key on its own: a dotted key like `"votes.u1"`
|
||||
* sets one nested value, a plain object replaces the whole nested object, a
|
||||
* marker (`deleteField()`, `sharedText()`, `increment()`, `arrayUnion()`,
|
||||
* `arrayRemove()` or another kit's) creates or changes that kind of field,
|
||||
* and `undefined` is skipped. `id` is ignored, so an item can be spread back
|
||||
* in; only the fields the spread changed are written.
|
||||
*/
|
||||
export interface Fields {
|
||||
[field: string]: JsonValue | FieldValue | undefined;
|
||||
}
|
||||
|
||||
/** Removes a field: `update({ [`votes.${uid}`]: deleteField() })`. */
|
||||
export function deleteField(): FieldValue {
|
||||
return deleteMarker();
|
||||
}
|
||||
|
||||
/** Creates a text everyone can type in together: `add({ body: sharedText() })`. Edit it with `SharedTextarea` or `SharedInput`; the field reads and writes as a string. */
|
||||
export function sharedText(initial?: string): FieldValue {
|
||||
return textMarker(initial);
|
||||
}
|
||||
|
||||
/** Adds to a number without losing anyone else's additions: `update({ likes: increment(1) })`; negative subtracts. The field reads as a number, and writing a plain number to it adjusts it by the difference. */
|
||||
export function increment(by: number): FieldValue {
|
||||
return incrementMarker(by);
|
||||
}
|
||||
|
||||
/** Adds values to an array, each once, without losing anyone else's: `update({ tags: arrayUnion("bug") })`, or `arrayUnion(...tags)` for several. The field reads as an array in the order values were added, and writing a plain array to it adds and removes the difference. */
|
||||
export function arrayUnion(...values: JsonValue[]): FieldValue {
|
||||
return unionMarker(...values);
|
||||
}
|
||||
|
||||
/** Removes values from an array: `update({ tags: arrayRemove("bug") })`. */
|
||||
export function arrayRemove(...values: JsonValue[]): FieldValue {
|
||||
return removeMarker(...values);
|
||||
}
|
||||
|
||||
export interface SharedDoc {
|
||||
/** False until the document is created with `set`; `update` throws before that. */
|
||||
exists: boolean;
|
||||
data: DocData;
|
||||
/** Creates or replaces the whole document. */
|
||||
set(fields: Fields): void;
|
||||
/** Merges into the document; throws if it does not exist. */
|
||||
update(fields: Fields | ((current: DocData) => Fields)): void;
|
||||
/** Deletes the document and its subcollections; nothing happens if it does not exist. */
|
||||
delete(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One document at a path like `game/state` or `cards/k3f9`; `defaults` fill
|
||||
* in missing top-level fields and are never written. A null path gives empty
|
||||
* data and methods that do nothing; suspends until the shared state has
|
||||
* loaded.
|
||||
*/
|
||||
export function useSharedDoc(
|
||||
path: string | null | undefined,
|
||||
defaults?: Fields,
|
||||
): SharedDoc {
|
||||
const key = path == null ? null : documentPath(path);
|
||||
const stableDefaults = useStable(defaults);
|
||||
const doc = useMemo(
|
||||
() => docHandle(key, stableDefaults),
|
||||
[key, stableDefaults],
|
||||
);
|
||||
if (key !== null) {
|
||||
getStore().suspendUntilSynced();
|
||||
}
|
||||
const data = useStoreValue(key, () => doc.data);
|
||||
return { ...doc, data };
|
||||
}
|
||||
|
||||
export interface SharedCollection {
|
||||
/** The child documents, in order, as of this render; `shared.collection(path).items` is always current. */
|
||||
items: readonly DocData[];
|
||||
/** Adds a document at the end and returns its id. Pass `id` in the fields to choose it. */
|
||||
add(fields: Fields): string;
|
||||
/** Creates or replaces the document with this `id`; returns the id. */
|
||||
set(id: string, fields: Fields): string;
|
||||
/** Merges into the item; throws if the id is unknown. */
|
||||
update(id: string, fields: Fields | ((current: DocData) => Fields)): void;
|
||||
/** Deletes the item and its subcollections; nothing happens if the id is unknown. */
|
||||
delete(id: string): void;
|
||||
/** Moves an item to an index, or before or after another item; `items` returns the new order. Throws if either id is unknown. */
|
||||
move(id: string, to: number | { before: string } | { after: string }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The documents at a collection path like `cards` or `cards/k3f9/steps`, in
|
||||
* order, and the methods to change them. A null path gives no items and
|
||||
* methods that do nothing; suspends until the shared state has loaded.
|
||||
*/
|
||||
export function useSharedCollection(
|
||||
path: string | null | undefined,
|
||||
): SharedCollection {
|
||||
const key = path == null ? null : collectionPath(path);
|
||||
const collection = useMemo(() => collectionHandle(key), [key]);
|
||||
if (key !== null) {
|
||||
getStore().suspendUntilSynced();
|
||||
}
|
||||
const items = useStoreValue(key, () => collection.items);
|
||||
return { ...collection, items };
|
||||
}
|
||||
|
||||
export type { User } from "../user";
|
||||
|
||||
/** One person viewing the artifact right now: who they are and their presence. */
|
||||
export interface Collaborator {
|
||||
user: User;
|
||||
/** Their presence; `{}` until they set one. */
|
||||
presence: JsonObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* This viewer: `user` is `clickup.viewer`, `presence` is what everyone else
|
||||
* sees of them live, `{}` until set, never saved.
|
||||
*/
|
||||
export function useMe(): Collaborator & {
|
||||
/** Sets what everyone else sees of you, like a `useState` setter: pass a new value or a function of the current one. Spread the current one to keep the keys other components write: `cursor`, `message` and `selection` from `Cursors`, `slide` from a slides `Deck`. */
|
||||
setPresence(
|
||||
presence: JsonObject | ((current: JsonObject) => JsonObject),
|
||||
): void;
|
||||
} {
|
||||
return useStoreValue(PRESENCE_KEY, () => shared.me);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone else viewing the artifact right now, one entry per person. With
|
||||
* `select`, only what it returns, re-rendering when that changes:
|
||||
* `useCollaborators((all) => all.filter((c) => c.presence.editing === id))`.
|
||||
*/
|
||||
export function useCollaborators<T = readonly Collaborator[]>(
|
||||
select?: (collaborators: readonly Collaborator[]) => T,
|
||||
): T {
|
||||
const last = useRef<{
|
||||
from: readonly Collaborator[];
|
||||
select: typeof select;
|
||||
value: T;
|
||||
}>();
|
||||
return useStoreValue(PRESENCE_KEY, () => {
|
||||
const from = shared.collaborators;
|
||||
if (!select) {
|
||||
return from as T;
|
||||
}
|
||||
const cached = last.current;
|
||||
if (cached && cached.from === from && cached.select === select) {
|
||||
return cached.value;
|
||||
}
|
||||
const next = select(from);
|
||||
const value = cached && deepEqual(cached.value, next) ? cached.value : next;
|
||||
last.current = { from, select, value };
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `listener` for each `name` broadcast another person sends while
|
||||
* mounted, with the payload and who sent it:
|
||||
* `useBroadcast("buzz", (payload, from) => flash(from.name))`. Broadcasts
|
||||
* are for what happens, documents for what must survive; a listener runs on
|
||||
* every other page, so a write in it lands once per viewer.
|
||||
*/
|
||||
export function useBroadcast(
|
||||
name: string,
|
||||
listener: (payload: JsonValue, from: User) => void,
|
||||
): void {
|
||||
const latest = useRef(listener);
|
||||
latest.current = listener;
|
||||
useEffect(
|
||||
() =>
|
||||
shared.onBroadcast(name, (payload, from) =>
|
||||
latest.current(payload, from),
|
||||
),
|
||||
[name],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo and redo this viewer's own changes; all writes from one event handler
|
||||
* are one step. While `canEdit` is false, both do nothing and both flags are
|
||||
* false.
|
||||
*/
|
||||
export function useUndo(): {
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
} {
|
||||
return {
|
||||
...useStoreValue(HISTORY_KEY, () => getStore().history),
|
||||
undo: shared.undo,
|
||||
redo: shared.redo,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SharedStatus {
|
||||
/** `connecting` until the shared state has loaded, and again while reconnecting; `disconnected` on a host without collaboration; otherwise the host's connection state. */
|
||||
connection: "connecting" | "connected" | "disconnected";
|
||||
/** False while loading, in read-only rooms and in outdated builds. Writes are then ignored. */
|
||||
canEdit: boolean;
|
||||
/** True when a newer build of this artifact is live. Ask the viewer to reload. */
|
||||
outdated: boolean;
|
||||
}
|
||||
|
||||
/** Whether the shared state is connected, whether you can edit it, and whether a newer build is live. */
|
||||
export function useSharedStatus(): SharedStatus {
|
||||
return useStoreValue(STATUS_KEY, () => shared.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared state outside React: the same documents, collections, presence,
|
||||
* broadcasts, undo and status as the hooks, always current. Use it in a draw
|
||||
* loop or in an event listener set up outside a component; in render, use
|
||||
* the hooks.
|
||||
*/
|
||||
export const shared: {
|
||||
/** The document at `path`, as `useSharedDoc` gives it; `data` and `exists` read the current state. */
|
||||
doc(path: string | null | undefined, defaults?: Fields): SharedDoc;
|
||||
/** The collection at `path`, as `useSharedCollection` gives it; `items` reads the current state. */
|
||||
collection(path: string | null | undefined): SharedCollection;
|
||||
/** This viewer, as `useMe` gives them. */
|
||||
readonly me: Collaborator & {
|
||||
setPresence(
|
||||
presence: JsonObject | ((current: JsonObject) => JsonObject),
|
||||
): void;
|
||||
};
|
||||
readonly collaborators: readonly Collaborator[];
|
||||
/** Sends `name` with `payload` to everyone else viewing the artifact right now; never saved, never replayed to anyone who arrives later. */
|
||||
broadcast(name: string, payload?: JsonValue): void;
|
||||
/** Calls `listener` with each `name` broadcast another person sends, as `useBroadcast` does. Returns a function that stops the calls. */
|
||||
onBroadcast(
|
||||
name: string,
|
||||
listener: (payload: JsonValue, from: User) => void,
|
||||
): () => void;
|
||||
readonly status: SharedStatus;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
readonly canUndo: boolean;
|
||||
readonly canRedo: boolean;
|
||||
/** Calls `listener` after every change to documents, presence, status or history. Returns a function that stops the calls. */
|
||||
subscribe(listener: () => void): () => void;
|
||||
/** Resolves once the shared state has loaded; until then writes are ignored. */
|
||||
ready(): Promise<void>;
|
||||
} = {
|
||||
doc: docHandle,
|
||||
collection: collectionHandle,
|
||||
get me() {
|
||||
return getStore().presence.me;
|
||||
},
|
||||
get collaborators() {
|
||||
return getStore().presence.collaborators;
|
||||
},
|
||||
broadcast: (name, payload) => getStore().presence.broadcast(name, payload),
|
||||
onBroadcast: (name, listener) =>
|
||||
getStore().presence.onBroadcast(name, listener),
|
||||
get status() {
|
||||
return getStore().status;
|
||||
},
|
||||
undo: () => getStore().undo(),
|
||||
redo: () => getStore().redo(),
|
||||
get canUndo() {
|
||||
return getStore().history.canUndo;
|
||||
},
|
||||
get canRedo() {
|
||||
return getStore().history.canRedo;
|
||||
},
|
||||
subscribe: (listener) => getStore().subscribe(ANY_KEY, listener),
|
||||
ready: () => getStore().ready(),
|
||||
};
|
||||
@@ -0,0 +1,467 @@
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
import type { RoomSession } from "../room";
|
||||
import { hostRoom } from "../room";
|
||||
import type { User } from "../user";
|
||||
import { viewer } from "../viewer";
|
||||
import type { Base, DocumentSnapshot, Leaves, OpaqueY } from "./fields";
|
||||
import {
|
||||
applyPatch,
|
||||
baseOf,
|
||||
deepEqual,
|
||||
isPlainObject,
|
||||
normalizePatch,
|
||||
own,
|
||||
} from "./fields";
|
||||
import type { Opaque } from "./kinds";
|
||||
import { Mirror, NO_ITEMS } from "./mirror";
|
||||
import { indexOf, placeAt } from "./order";
|
||||
import { childDocumentPath, parentCollectionOf, validateName } from "./paths";
|
||||
import { PresenceStore } from "./presence";
|
||||
import type { DocData, Fields, SharedStatus } from "./shared";
|
||||
|
||||
/** A text control's handle on one field: its value, its edits, and caret anchors that survive remote edits. */
|
||||
export interface TextBinding {
|
||||
editable(): boolean;
|
||||
value(): string;
|
||||
edit(next: string, caret: number): void;
|
||||
anchor(index: number): unknown;
|
||||
indexOf(anchor: unknown): number | undefined;
|
||||
}
|
||||
|
||||
/** What a kit binds to for an `opaque(y, of)` field of its own; getters read the current state. */
|
||||
export interface OpaqueField<T> {
|
||||
/** The instance while the field holds an opaque `y` of `of`; otherwise undefined. */
|
||||
readonly value: T | undefined;
|
||||
/** What the field holds when it is opaque of another `y`/`of`, for the caller's error message. */
|
||||
readonly holds: { y: OpaqueY; of: string } | undefined;
|
||||
/** False while `value` is undefined, the room is read-only or this build is outdated; true without a value only on a host without collaboration, where edits stay in this tab. */
|
||||
readonly canEdit: boolean;
|
||||
/** Undefined until the room is open. */
|
||||
readonly awareness: Awareness | undefined;
|
||||
/** Runs `change` as one transaction the kit's undo ignores, when writes are allowed. */
|
||||
write(change: () => void): void;
|
||||
}
|
||||
|
||||
export const NO_DATA: ReadonlyArray<DocData> = Object.freeze([]);
|
||||
|
||||
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const ID_LENGTH = 10;
|
||||
const itemsOf = new WeakMap<
|
||||
readonly DocumentSnapshot[],
|
||||
ReadonlyArray<DocData>
|
||||
>();
|
||||
|
||||
/** Subscription keys beside document and collection paths; `#any` is told on every change. */
|
||||
export const PRESENCE_KEY = "#presence";
|
||||
export const STATUS_KEY = "#status";
|
||||
export const HISTORY_KEY = "#history";
|
||||
export const ANY_KEY = "#any";
|
||||
|
||||
/**
|
||||
* Shared state over one room: every write is validated at its call site and
|
||||
* applied as its own transaction, and one event handler's writes form one
|
||||
* undo step. Listeners subscribe by key, a document or collection path or one
|
||||
* of the `#` keys, and are told only when that key changed.
|
||||
*/
|
||||
export class SharedStore {
|
||||
status: SharedStatus = {
|
||||
connection: "connecting",
|
||||
canEdit: false,
|
||||
outdated: false,
|
||||
};
|
||||
history = { canUndo: false, canRedo: false };
|
||||
readonly presence: PresenceStore;
|
||||
private readonly listeners = new Map<string, Set<() => void>>();
|
||||
private mirror: Mirror | undefined;
|
||||
private grouping = false;
|
||||
|
||||
constructor(
|
||||
private readonly session: RoomSession,
|
||||
private readonly build: number,
|
||||
viewer: User,
|
||||
) {
|
||||
this.presence = new PresenceStore(viewer, () =>
|
||||
this.notify([PRESENCE_KEY]),
|
||||
);
|
||||
session.subscribe(() => this.sync());
|
||||
this.sync();
|
||||
}
|
||||
|
||||
subscribe(key: string, listener: () => void): () => void {
|
||||
const listeners = this.listeners.get(key) ?? new Set<() => void>();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(key, listeners);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
this.listeners.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves once the room is usable, or has failed to open; it stays resolved. */
|
||||
ready(): Promise<void> {
|
||||
return this.session.ready();
|
||||
}
|
||||
|
||||
/** Throws `ready()` for React to suspend on until the room is usable. */
|
||||
suspendUntilSynced(): void {
|
||||
if (!this.session.isReady()) {
|
||||
throw this.ready();
|
||||
}
|
||||
}
|
||||
|
||||
/** True on a host without collaboration: no document will ever load, so a text editor edits locally, for this tab. */
|
||||
get standalone(): boolean {
|
||||
return this.session.state.kind === "unavailable";
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.status.canEdit) {
|
||||
this.mirror?.undo.undo();
|
||||
}
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
if (this.status.canEdit) {
|
||||
this.mirror?.undo.redo();
|
||||
}
|
||||
}
|
||||
|
||||
/** The document's data, or null when it does not exist. */
|
||||
data(path: string): DocData | null {
|
||||
return this.mirror?.document(path)?.data ?? null;
|
||||
}
|
||||
|
||||
/** The collection's items, one frozen array per collection state. */
|
||||
items(path: string): ReadonlyArray<DocData> {
|
||||
const snapshots = this.mirror?.collection(path) ?? NO_ITEMS;
|
||||
let items = itemsOf.get(snapshots);
|
||||
if (!items) {
|
||||
items = Object.freeze(snapshots.map((snapshot) => snapshot.data));
|
||||
itemsOf.set(snapshots, items);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
set(path: string, fields: Fields): void {
|
||||
const base = baseOf(fields);
|
||||
const patch = documentPatch(fields);
|
||||
this.write((mirror) => {
|
||||
const current = mirror.document(path)?.data;
|
||||
if (current && base?.path === path) {
|
||||
for (const key of untouched(patch, base)) {
|
||||
const now = own(current, key);
|
||||
if (now === undefined) {
|
||||
patch.delete(key);
|
||||
} else {
|
||||
patch.set(key, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
const leaves: Leaves = new Map();
|
||||
applyPatch(leaves, patch);
|
||||
mirror.setDocument(path, leaves, base?.path);
|
||||
});
|
||||
}
|
||||
|
||||
update(path: string, patch: Fields | ((current: DocData) => Fields)): void {
|
||||
const resolved =
|
||||
typeof patch === "function" ? patch : intendedPatch(path, patch);
|
||||
this.write((mirror) => {
|
||||
if (!mirror.has(path)) {
|
||||
throw new Error(
|
||||
`update() failed: "${path}" does not exist. Create it with set() or add() first, or check exists.`,
|
||||
);
|
||||
}
|
||||
mirror.updateDocument(
|
||||
path,
|
||||
typeof resolved === "function"
|
||||
? intendedPatch(path, resolved(mirror.document(path)!.data))
|
||||
: resolved,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
delete(path: string): void {
|
||||
this.write((mirror) => mirror.deleteDocument(path));
|
||||
}
|
||||
|
||||
add(collection: string, fields: Fields): string {
|
||||
const chosen =
|
||||
isPlainObject(fields) && fields.id !== baseOf(fields)?.data.id
|
||||
? fields.id
|
||||
: undefined;
|
||||
const id =
|
||||
chosen === undefined ? generateId() : validateName(chosen, "An id");
|
||||
this.set(childDocumentPath(collection, id), fields);
|
||||
return id;
|
||||
}
|
||||
|
||||
move(collection: string, id: string, target: MoveTarget): void {
|
||||
const path = childDocumentPath(collection, id);
|
||||
const placement = placementOf(collection, target);
|
||||
this.write((mirror) => {
|
||||
const items = mirror.collection(collection);
|
||||
const at = (member: string) => {
|
||||
const snapshot = mirror.document(member);
|
||||
return snapshot ? indexOf(items, snapshot) : -1;
|
||||
};
|
||||
const from = at(path);
|
||||
if (from === -1) {
|
||||
throw new Error(`move() failed: "${path}" does not exist.`);
|
||||
}
|
||||
let index: number;
|
||||
if ("index" in placement) {
|
||||
index = Math.max(0, Math.min(placement.index, items.length - 1));
|
||||
} else {
|
||||
const to = at(placement.neighbour);
|
||||
if (to === -1) {
|
||||
throw new Error(
|
||||
`move() failed: the neighbour "${placement.neighbour}" does not exist.`,
|
||||
);
|
||||
}
|
||||
if (to === from) {
|
||||
return;
|
||||
}
|
||||
index = (to > from ? to - 1 : to) + (placement.after ? 1 : 0);
|
||||
}
|
||||
if (index === from) {
|
||||
return;
|
||||
}
|
||||
const { key, repairs } = placeAt(items, from, index);
|
||||
for (const write of [{ id, order: key }, ...repairs]) {
|
||||
mirror.setOrder(childDocumentPath(collection, write.id), write.order);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
text(path: string, field: string): TextBinding {
|
||||
return {
|
||||
editable: () => this.mirror?.textAt(path, field) !== undefined,
|
||||
value: () => this.mirror?.textAt(path, field)?.toString() ?? "",
|
||||
edit: (next, caret) =>
|
||||
this.apply((mirror) => mirror.editText(path, field, next, caret)),
|
||||
anchor: (index) => this.mirror?.anchor(path, field, index),
|
||||
indexOf: (anchor) => (anchor ? this.mirror?.indexOf(anchor) : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** What `field` holds as an opaque field, whatever its labels. */
|
||||
opaqueAt(path: string, field: string): Opaque | undefined {
|
||||
return this.mirror?.opaqueAt(path, field);
|
||||
}
|
||||
|
||||
/** The field for the kit driving an opaque `y` of `of` there; `holds` keeps one object while the labels it names stay. */
|
||||
opaque<T>(
|
||||
path: string,
|
||||
field: string,
|
||||
y: OpaqueY,
|
||||
of: string,
|
||||
): OpaqueField<T> {
|
||||
const store = this;
|
||||
const mine = (held: Opaque) => held.y === y && held.of === of;
|
||||
let holds: OpaqueField<T>["holds"];
|
||||
return {
|
||||
get value() {
|
||||
const held = store.opaqueAt(path, field);
|
||||
return held && mine(held) ? (held.value as T) : undefined;
|
||||
},
|
||||
get holds() {
|
||||
const held = store.opaqueAt(path, field);
|
||||
if (!held || mine(held)) {
|
||||
return undefined;
|
||||
}
|
||||
if (holds?.y !== held.y || holds.of !== held.of) {
|
||||
holds = { y: held.y, of: held.of };
|
||||
}
|
||||
return holds;
|
||||
},
|
||||
get canEdit() {
|
||||
return this.value !== undefined
|
||||
? store.status.canEdit
|
||||
: store.standalone;
|
||||
},
|
||||
get awareness() {
|
||||
return store.mirror?.room.awareness;
|
||||
},
|
||||
write: (change) => {
|
||||
const mirror = this.mirror;
|
||||
if (mirror && this.status.canEdit) {
|
||||
mirror.transactOpaque(change);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs one change as its own transaction, when writes are allowed. */
|
||||
private apply(change: (mirror: Mirror) => void) {
|
||||
const mirror = this.mirror;
|
||||
if (mirror && this.status.canEdit) {
|
||||
mirror.transact(() => change(mirror));
|
||||
}
|
||||
}
|
||||
|
||||
/** A document write: the first of a tick starts a new undo step and the tick's end closes it; a tick that wrote nothing leaves the previous step open. */
|
||||
private write(change: (mirror: Mirror) => void) {
|
||||
this.apply((mirror) => {
|
||||
if (!this.grouping) {
|
||||
this.grouping = true;
|
||||
const { undo } = mirror;
|
||||
const lastChange = undo.lastChange;
|
||||
undo.stopCapturing();
|
||||
queueMicrotask(() => {
|
||||
this.grouping = false;
|
||||
if (undo.lastChange === 0) {
|
||||
undo.lastChange = lastChange;
|
||||
} else {
|
||||
undo.stopCapturing();
|
||||
}
|
||||
});
|
||||
}
|
||||
change(mirror);
|
||||
});
|
||||
}
|
||||
|
||||
/** Follows the session: a new room gets a new mirror and every hook is told; the build is judged once the room has synced. */
|
||||
private sync() {
|
||||
const state = this.session.state;
|
||||
const room = state.kind === "open" ? state.room : undefined;
|
||||
const changed = this.mirror?.room !== room;
|
||||
if (changed) {
|
||||
this.mirror?.dispose();
|
||||
this.mirror = room && new Mirror(room, (paths) => this.changed(paths));
|
||||
this.presence.bind(room);
|
||||
}
|
||||
if (this.mirror && room?.status.synced && this.build > 0) {
|
||||
this.mirror.watchBuild(this.build);
|
||||
}
|
||||
const keys = this.refresh();
|
||||
this.notify(changed ? this.listeners.keys() : keys);
|
||||
}
|
||||
|
||||
/** Notifies the changed documents, their collections, and whatever status and history moved. */
|
||||
private changed(paths: ReadonlySet<string>) {
|
||||
const keys = this.refresh();
|
||||
for (const path of paths) {
|
||||
keys.push(path, parentCollectionOf(path));
|
||||
}
|
||||
this.notify(keys);
|
||||
}
|
||||
|
||||
/** Recomputes status and history, keeping their identity while unchanged; returns the keys of what changed. */
|
||||
private refresh(): string[] {
|
||||
const state = this.session.state;
|
||||
const synced =
|
||||
state.kind === "open" && state.room.status.synced
|
||||
? state.room.status
|
||||
: undefined;
|
||||
const outdated = this.mirror?.outdated ?? false;
|
||||
const status: SharedStatus = {
|
||||
connection: synced
|
||||
? (synced.upstream ?? (synced.connected ? "connected" : "disconnected"))
|
||||
: state.kind === "unavailable"
|
||||
? "disconnected"
|
||||
: "connecting",
|
||||
canEdit: Boolean(synced && !synced.readOnly && !outdated),
|
||||
outdated,
|
||||
};
|
||||
const keys: string[] = [];
|
||||
if (!deepEqual(this.status, status)) {
|
||||
this.status = status;
|
||||
keys.push(STATUS_KEY);
|
||||
}
|
||||
const history = {
|
||||
canUndo: status.canEdit && Boolean(this.mirror?.undo.canUndo()),
|
||||
canRedo: status.canEdit && Boolean(this.mirror?.undo.canRedo()),
|
||||
};
|
||||
if (!deepEqual(this.history, history)) {
|
||||
this.history = history;
|
||||
keys.push(HISTORY_KEY);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private notify(keys: Iterable<string>) {
|
||||
const changed = new Set(keys);
|
||||
if (changed.size > 0) {
|
||||
changed.add(ANY_KEY);
|
||||
}
|
||||
for (const key of changed) {
|
||||
this.listeners.get(key)?.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A validated document patch without `id`. */
|
||||
function documentPatch(patch: unknown): Leaves {
|
||||
const leaves = normalizePatch(patch);
|
||||
leaves.delete("id");
|
||||
return leaves;
|
||||
}
|
||||
|
||||
/** A document patch without the fields a spread of `path`'s snapshot left as they were. */
|
||||
function intendedPatch(path: string, fields: unknown): Leaves {
|
||||
const leaves = documentPatch(fields);
|
||||
const base = baseOf(fields);
|
||||
if (base?.path === path) {
|
||||
for (const key of untouched(leaves, base)) {
|
||||
leaves.delete(key);
|
||||
}
|
||||
}
|
||||
return leaves;
|
||||
}
|
||||
|
||||
/** The keys of `patch` still holding what `base` showed. */
|
||||
function untouched(patch: Leaves, base: Base): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [key, value] of patch) {
|
||||
if (deepEqual(value, own(base.data, key))) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
type MoveTarget = number | { before: string } | { after: string };
|
||||
type Placement = { index: number } | { neighbour: string; after: boolean };
|
||||
|
||||
/** A move target as an index, or a neighbour's path and a side; throws on anything else. */
|
||||
function placementOf(collection: string, target: MoveTarget): Placement {
|
||||
if (typeof target === "number" && Number.isInteger(target)) {
|
||||
return { index: target };
|
||||
}
|
||||
const named: Record<string, unknown> = isPlainObject(target) ? target : {};
|
||||
const after = typeof named.before !== "string";
|
||||
const id = after ? named.after : named.before;
|
||||
if (typeof id !== "string") {
|
||||
throw new Error(
|
||||
`move() expects an index or { before: id } / { after: id }, got ${JSON.stringify(target)}.`,
|
||||
);
|
||||
}
|
||||
return { neighbour: childDocumentPath(collection, id), after };
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(ID_LENGTH));
|
||||
return Array.from(
|
||||
bytes,
|
||||
(byte) => ID_ALPHABET[byte % ID_ALPHABET.length],
|
||||
).join("");
|
||||
}
|
||||
|
||||
let store: SharedStore | undefined;
|
||||
|
||||
/** The page's one store, opened on first use. */
|
||||
export function getStore(): SharedStore {
|
||||
if (!store) {
|
||||
const host = globalThis as { __ARTIFACT_BUILD__?: unknown };
|
||||
const build = Number(host.__ARTIFACT_BUILD__ ?? 0);
|
||||
store = new SharedStore(
|
||||
hostRoom(),
|
||||
Number.isFinite(build) ? build : 0,
|
||||
viewer(),
|
||||
);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useRef, useSyncExternalStore } from "react";
|
||||
import { deepEqual } from "./fields";
|
||||
import { getStore } from "./store";
|
||||
|
||||
const unsubscribed = () => {};
|
||||
const inert = () => unsubscribed;
|
||||
|
||||
/** `read()` kept current by the store's notifications for `key`; a null key reads once and never re-renders. */
|
||||
export function useStoreValue<T>(key: string | null, read: () => T): T {
|
||||
const store = getStore();
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) =>
|
||||
key === null ? inert() : store.subscribe(key, listener),
|
||||
[store, key],
|
||||
);
|
||||
return useSyncExternalStore(subscribe, read, read);
|
||||
}
|
||||
|
||||
/** The same object across renders while it stays deep-equal. */
|
||||
export function useStable<T>(value: T): T {
|
||||
const ref = useRef(value);
|
||||
if (!deepEqual(ref.current, value)) {
|
||||
ref.current = value;
|
||||
}
|
||||
return ref.current;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const warned = new Set<string>();
|
||||
|
||||
export function warnOnce(key: string, message: string): void {
|
||||
if (!warned.has(key)) {
|
||||
warned.add(key);
|
||||
console.warn(`[sync] ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** A person, the same shape as the ClickUp kit's `User`. */
|
||||
export 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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ArtifactMemberDto } from "@time-loop/artifacts-protocol";
|
||||
import type { User } from "./user";
|
||||
|
||||
const NOBODY: User = Object.freeze({ kind: "user", id: "", name: "", url: "" });
|
||||
|
||||
/** The host's viewer as a `User`; nobody on a host that cannot name them. */
|
||||
export function viewer(): User {
|
||||
const host = globalThis as {
|
||||
clickup?: { getViewer?(): ArtifactMemberDto };
|
||||
};
|
||||
const member = host.clickup?.getViewer?.();
|
||||
return member
|
||||
? {
|
||||
kind: "user",
|
||||
id: member.id,
|
||||
name: member.displayName,
|
||||
url: member.url,
|
||||
image: member.avatarUrl,
|
||||
}
|
||||
: NOBODY;
|
||||
}
|
||||
Reference in New Issue
Block a user