@kolosal-ai/rivet 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ import { P as PeerRecord } from '../use-node-lock-DH0dB1yA.js';
2
+ export { C as CanInteractWithLocked, D as DEFAULT_PRESENCE_OPTIONS, L as LOCK_DEFAULT_REFUSED, a as LocalPresence, b as LockIntent, c as LockInteraction, d as LockRegistry, N as NodeLock, e as PEER_COLORS, f as Peer, g as PresenceOptions, h as PresenceRegistry, R as ResolvedPresenceOptions, p as peerColor, u as useNodeLock, i as useNodeLockAllows } from '../use-node-lock-DH0dB1yA.js';
3
+ import { a as NodeChange } from '../types-C9Vn1rzy.js';
4
+ import 'react';
5
+
6
+ /**
7
+ * The peers currently in the document — for participant chrome you render
8
+ * yourself: an avatar stack, a follow button, a "3 others editing" line.
9
+ *
10
+ * Re-renders when the **roster** changes: someone joins or leaves, is renamed,
11
+ * or changes what they have selected or held. It deliberately does *not*
12
+ * re-render on cursor frames — those arrive at pointer rate and are already
13
+ * painted on the canvas. If you're rendering cursors in the DOM yourself (with
14
+ * `presenceOptions={{ renderCursors: false }}`), read them off the frame
15
+ * channel instead, where the rest of the imperative chrome lives.
16
+ */
17
+ declare function usePeers(): readonly PeerRecord[];
18
+
19
+ /**
20
+ * Which local changes belong on the wire, and at which speed.
21
+ *
22
+ * Publishing a change batch to peers means answering three questions per
23
+ * change: is this the document (send it, keep it), is it a live gesture frame
24
+ * (send it, throw it away on the next one), or is it this client talking to
25
+ * itself (never send it). The answers are rivet semantics — they depend on
26
+ * which fields are gesture-bounded and which flags mark a frame as uncommitted
27
+ * — and every one of them fails *silently* when a consumer guesses wrong:
28
+ * nothing throws, the shared graph just misbehaves in ways that read like
29
+ * transport bugs.
30
+ *
31
+ * So the classification lives here. Transport policy does not: throttling the
32
+ * ephemeral half, batching or coalescing the durable half, and deciding what a
33
+ * frame even looks like on your wire are all yours.
34
+ */
35
+
36
+ /**
37
+ * A change that can carry an uncommitted gesture frame — the only two kinds a
38
+ * gesture produces mid-flight. Narrower than {@link NodeChange} so that the
39
+ * ephemeral half always has an `id` to key a transform by.
40
+ */
41
+ type EphemeralNodeChange<TData = unknown> = Extract<NodeChange<TData>, {
42
+ type: "position" | "dimensions";
43
+ }>;
44
+ /** The two halves of a local batch, as returned by {@link splitLocalNodeChanges}. */
45
+ type LocalNodeChangeSplit<TData = unknown> = {
46
+ /**
47
+ * Document mutations. Send these, and expect peers to keep them: they're
48
+ * what a client that reloads has to see.
49
+ */
50
+ durable: NodeChange<TData>[];
51
+ /**
52
+ * Uncommitted gesture frames — where this client currently has a node, not
53
+ * where it has agreed to put it. Send them as ephemeral state (rivet's own
54
+ * inbound door for this is `setPeerNodeTransform`, which paints without
55
+ * touching the graph); drop them freely under load, because the commit that
56
+ * closes the gesture arrives in {@link LocalNodeChangeSplit.durable}.
57
+ *
58
+ * One gesture frame can appear twice in a batch — a resize dragged from a
59
+ * top-left handle emits both a `dimensions` and a `position` change for the
60
+ * same node — so key by `id` rather than counting.
61
+ */
62
+ ephemeral: EphemeralNodeChange<TData>[];
63
+ };
64
+ /**
65
+ * Split a local `onNodesChange` batch into the half peers must keep and the
66
+ * half they only need until the next frame. Changes that must not travel at
67
+ * all are in neither array.
68
+ *
69
+ * | change | bucket | why |
70
+ * | --- | --- | --- |
71
+ * | `add` / `remove` / `replace` | durable | the document |
72
+ * | `position`, `dragging` unset or `false` | durable | the commit that closes a move |
73
+ * | `position` with `dragging: true` | ephemeral | a live drag frame |
74
+ * | `dimensions` with `resizing: false` | durable | the commit that closes a resize |
75
+ * | `dimensions` with `resizing: true` | ephemeral | a live resize frame |
76
+ * | `dimensions` with no `resizing` flag | **dropped** | a measurement |
77
+ * | `select` | **dropped** | selection is presence |
78
+ *
79
+ * The two drops are the ones worth having in the library. A `select` on the
80
+ * wire makes every client select in unison — selection is a fact about a
81
+ * person, and it travels through `onLocalPresence` instead. And an unflagged
82
+ * `dimensions` is this client reporting its own measured DOM box, which every
83
+ * client produces for itself; publishing yours makes peers fight each other's
84
+ * layout. That distinction — a measurement echo versus a real resize — is
85
+ * invisible from outside without reading the store.
86
+ *
87
+ * Only call this on batches whose `ChangeMeta.origin` is `"local"`; a batch
88
+ * re-emitted after `applyRemote` is a peer's edit coming back, and sending it
89
+ * on is the echo loop.
90
+ *
91
+ * ```ts
92
+ * onNodesChange={(changes, meta) => {
93
+ * setNodes((current) => applyNodeChanges(changes, current))
94
+ * if (meta.origin !== "local") return
95
+ * const { durable, ephemeral } = splitLocalNodeChanges(changes)
96
+ * if (durable.length > 0) channel.send({ kind: "nodes", changes: durable })
97
+ * for (const change of ephemeral) {
98
+ * channel.send({ kind: "transform", id: change.id, rect: rivet.getNodeRect(change.id) })
99
+ * }
100
+ * }}
101
+ * ```
102
+ *
103
+ * Edge changes need no equivalent: they carry no gesture-bounded fields, so
104
+ * dropping `select` is the whole rule.
105
+ */
106
+ declare function splitLocalNodeChanges<TData = unknown>(changes: readonly NodeChange<TData>[]): LocalNodeChangeSplit<TData>;
107
+
108
+ export { type EphemeralNodeChange, type LocalNodeChangeSplit, PeerRecord, splitLocalNodeChanges, usePeers };
@@ -0,0 +1,38 @@
1
+ export { DEFAULT_PRESENCE_OPTIONS, LOCK_DEFAULT_REFUSED, PEER_COLORS, peerColor, useNodeLock, useNodeLockAllows } from '../chunk-A52HZWLH.js';
2
+ import { useRivetContext } from '../chunk-VQYN27OK.js';
3
+ import { useSyncExternalStore } from 'react';
4
+
5
+ function usePeers() {
6
+ const { store } = useRivetContext();
7
+ return useSyncExternalStore(
8
+ store.presence.subscribe,
9
+ store.presence.getPeers,
10
+ store.presence.getPeers
11
+ );
12
+ }
13
+
14
+ // src/presence/changes.ts
15
+ function splitLocalNodeChanges(changes) {
16
+ const durable = [];
17
+ const ephemeral = [];
18
+ for (const change of changes) {
19
+ if (change.type === "select") continue;
20
+ if (change.type === "position") {
21
+ if (change.dragging) ephemeral.push(change);
22
+ else durable.push(change);
23
+ continue;
24
+ }
25
+ if (change.type === "dimensions") {
26
+ if (change.resizing === void 0) continue;
27
+ if (change.resizing) ephemeral.push(change);
28
+ else durable.push(change);
29
+ continue;
30
+ }
31
+ durable.push(change);
32
+ }
33
+ return { durable, ephemeral };
34
+ }
35
+
36
+ export { splitLocalNodeChanges, usePeers };
37
+ //# sourceMappingURL=index.js.map
38
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/hooks/use-peers.ts","../../src/presence/changes.ts"],"names":[],"mappings":";;;;AAeO,SAAS,QAAA,GAAkC;AAChD,EAAA,MAAM,EAAE,KAAA,EAAM,GAAI,eAAA,EAAgB;AAClC,EAAA,OAAO,oBAAA;AAAA,IACL,MAAM,QAAA,CAAS,SAAA;AAAA,IACf,MAAM,QAAA,CAAS,QAAA;AAAA,IACf,MAAM,QAAA,CAAS;AAAA,GACjB;AACF;;;ACsEO,SAAS,sBACd,OAAA,EAC6B;AAC7B,EAAA,MAAM,UAA+B,EAAC;AACtC,EAAA,MAAM,YAA0C,EAAC;AAEjD,EAAA,KAAA,MAAW,UAAU,OAAA,EAAS;AAC5B,IAAA,IAAI,MAAA,CAAO,SAAS,QAAA,EAAU;AAE9B,IAAA,IAAI,MAAA,CAAO,SAAS,UAAA,EAAY;AAC9B,MAAA,IAAI,MAAA,CAAO,QAAA,EAAU,SAAA,CAAU,IAAA,CAAK,MAAM,CAAA;AAAA,WACrC,OAAA,CAAQ,KAAK,MAAM,CAAA;AACxB,MAAA;AAAA,IACF;AAEA,IAAA,IAAI,MAAA,CAAO,SAAS,YAAA,EAAc;AAEhC,MAAA,IAAI,MAAA,CAAO,aAAa,MAAA,EAAW;AACnC,MAAA,IAAI,MAAA,CAAO,QAAA,EAAU,SAAA,CAAU,IAAA,CAAK,MAAM,CAAA;AAAA,WACrC,OAAA,CAAQ,KAAK,MAAM,CAAA;AACxB,MAAA;AAAA,IACF;AAEA,IAAA,OAAA,CAAQ,KAAK,MAAM,CAAA;AAAA,EACrB;AAEA,EAAA,OAAO,EAAE,SAAS,SAAA,EAAU;AAC9B","file":"index.js","sourcesContent":["import { useSyncExternalStore } from \"react\"\nimport { useRivetContext } from \"../context\"\nimport type { PeerRecord } from \"../presence/registry\"\n\n/**\n * The peers currently in the document — for participant chrome you render\n * yourself: an avatar stack, a follow button, a \"3 others editing\" line.\n *\n * Re-renders when the **roster** changes: someone joins or leaves, is renamed,\n * or changes what they have selected or held. It deliberately does *not*\n * re-render on cursor frames — those arrive at pointer rate and are already\n * painted on the canvas. If you're rendering cursors in the DOM yourself (with\n * `presenceOptions={{ renderCursors: false }}`), read them off the frame\n * channel instead, where the rest of the imperative chrome lives.\n */\nexport function usePeers(): readonly PeerRecord[] {\n const { store } = useRivetContext()\n return useSyncExternalStore(\n store.presence.subscribe,\n store.presence.getPeers,\n store.presence.getPeers,\n )\n}\n","/**\n * Which local changes belong on the wire, and at which speed.\n *\n * Publishing a change batch to peers means answering three questions per\n * change: is this the document (send it, keep it), is it a live gesture frame\n * (send it, throw it away on the next one), or is it this client talking to\n * itself (never send it). The answers are rivet semantics — they depend on\n * which fields are gesture-bounded and which flags mark a frame as uncommitted\n * — and every one of them fails *silently* when a consumer guesses wrong:\n * nothing throws, the shared graph just misbehaves in ways that read like\n * transport bugs.\n *\n * So the classification lives here. Transport policy does not: throttling the\n * ephemeral half, batching or coalescing the durable half, and deciding what a\n * frame even looks like on your wire are all yours.\n */\n\nimport type { NodeChange } from \"../types\"\n\n/**\n * A change that can carry an uncommitted gesture frame — the only two kinds a\n * gesture produces mid-flight. Narrower than {@link NodeChange} so that the\n * ephemeral half always has an `id` to key a transform by.\n */\nexport type EphemeralNodeChange<TData = unknown> = Extract<\n NodeChange<TData>,\n { type: \"position\" | \"dimensions\" }\n>\n\n/** The two halves of a local batch, as returned by {@link splitLocalNodeChanges}. */\nexport type LocalNodeChangeSplit<TData = unknown> = {\n /**\n * Document mutations. Send these, and expect peers to keep them: they're\n * what a client that reloads has to see.\n */\n durable: NodeChange<TData>[]\n /**\n * Uncommitted gesture frames — where this client currently has a node, not\n * where it has agreed to put it. Send them as ephemeral state (rivet's own\n * inbound door for this is `setPeerNodeTransform`, which paints without\n * touching the graph); drop them freely under load, because the commit that\n * closes the gesture arrives in {@link LocalNodeChangeSplit.durable}.\n *\n * One gesture frame can appear twice in a batch — a resize dragged from a\n * top-left handle emits both a `dimensions` and a `position` change for the\n * same node — so key by `id` rather than counting.\n */\n ephemeral: EphemeralNodeChange<TData>[]\n}\n\n/**\n * Split a local `onNodesChange` batch into the half peers must keep and the\n * half they only need until the next frame. Changes that must not travel at\n * all are in neither array.\n *\n * | change | bucket | why |\n * | --- | --- | --- |\n * | `add` / `remove` / `replace` | durable | the document |\n * | `position`, `dragging` unset or `false` | durable | the commit that closes a move |\n * | `position` with `dragging: true` | ephemeral | a live drag frame |\n * | `dimensions` with `resizing: false` | durable | the commit that closes a resize |\n * | `dimensions` with `resizing: true` | ephemeral | a live resize frame |\n * | `dimensions` with no `resizing` flag | **dropped** | a measurement |\n * | `select` | **dropped** | selection is presence |\n *\n * The two drops are the ones worth having in the library. A `select` on the\n * wire makes every client select in unison — selection is a fact about a\n * person, and it travels through `onLocalPresence` instead. And an unflagged\n * `dimensions` is this client reporting its own measured DOM box, which every\n * client produces for itself; publishing yours makes peers fight each other's\n * layout. That distinction — a measurement echo versus a real resize — is\n * invisible from outside without reading the store.\n *\n * Only call this on batches whose `ChangeMeta.origin` is `\"local\"`; a batch\n * re-emitted after `applyRemote` is a peer's edit coming back, and sending it\n * on is the echo loop.\n *\n * ```ts\n * onNodesChange={(changes, meta) => {\n * setNodes((current) => applyNodeChanges(changes, current))\n * if (meta.origin !== \"local\") return\n * const { durable, ephemeral } = splitLocalNodeChanges(changes)\n * if (durable.length > 0) channel.send({ kind: \"nodes\", changes: durable })\n * for (const change of ephemeral) {\n * channel.send({ kind: \"transform\", id: change.id, rect: rivet.getNodeRect(change.id) })\n * }\n * }}\n * ```\n *\n * Edge changes need no equivalent: they carry no gesture-bounded fields, so\n * dropping `select` is the whole rule.\n */\nexport function splitLocalNodeChanges<TData = unknown>(\n changes: readonly NodeChange<TData>[],\n): LocalNodeChangeSplit<TData> {\n const durable: NodeChange<TData>[] = []\n const ephemeral: EphemeralNodeChange<TData>[] = []\n\n for (const change of changes) {\n if (change.type === \"select\") continue\n\n if (change.type === \"position\") {\n if (change.dragging) ephemeral.push(change)\n else durable.push(change)\n continue\n }\n\n if (change.type === \"dimensions\") {\n // No flag at all means nobody resized: the store measured the DOM.\n if (change.resizing === undefined) continue\n if (change.resizing) ephemeral.push(change)\n else durable.push(change)\n continue\n }\n\n durable.push(change)\n }\n\n return { durable, ephemeral }\n}\n"]}
@@ -1,4 +1,4 @@
1
- import { H as HandlePosition, N as NodeId, C as Connection } from './types-B8AAJ60T.js';
1
+ import { H as HandlePosition, N as NodeId, C as Connection } from './types-C9Vn1rzy.js';
2
2
 
3
3
  /**
4
4
  * Anchor — a connectable point bound to a DOM element rendered inside a node.
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { CSSProperties } from 'react';
3
- import { g as AnchorStrayPolicy } from '../registry-Dkk4ZKt-.js';
4
- import '../types-B8AAJ60T.js';
3
+ import { g as AnchorStrayPolicy } from '../registry-CCBgd7FN.js';
4
+ import '../types-C9Vn1rzy.js';
5
5
 
6
6
  /**
7
7
  * The extract seam behind `<SvgArtwork>` — which elements inside a parsed
@@ -1,5 +1,5 @@
1
- import { R as Rect, c as RivetNode, V as Vec2, S as Size, k as SwimlaneMargin, j as SwimlaneGroup } from '../types-B8AAJ60T.js';
2
- export { L as LaneChange, M as Swimlane, l as SwimlaneHeaderProps, m as SwimlaneLabelProps, n as SwimlaneSizeChange } from '../types-B8AAJ60T.js';
1
+ import { R as Rect, c as RivetNode, V as Vec2, S as Size, s as SwimlaneMargin, r as SwimlaneGroup } from '../types-C9Vn1rzy.js';
2
+ export { L as LaneChange, U as Swimlane, t as SwimlaneHeaderProps, u as SwimlaneLabelProps, v as SwimlaneSizeChange } from '../types-C9Vn1rzy.js';
3
3
  import 'react';
4
4
 
5
5
  /** World height of a group's sticky header band. */
@@ -205,6 +205,60 @@ type EdgeChange<TData = unknown> = {
205
205
  id: EdgeId;
206
206
  item: RivetEdge<TData>;
207
207
  };
208
+ /**
209
+ * Where a change batch came from.
210
+ *
211
+ * - `"local"` — this client produced it (a gesture, a `useRivet()` call). In a
212
+ * multiplayer app this is the batch you send to your server.
213
+ * - `"remote"` — it arrived through {@link RivetStore.applyRemote}, i.e. it has
214
+ * already happened elsewhere. Apply it to your state, but don't send it back
215
+ * or the echo becomes a loop.
216
+ *
217
+ * Any other string is passed through untouched, so you can carry a peer id or a
218
+ * transaction id if you'd rather route on that.
219
+ */
220
+ type ChangeOrigin = "local" | "remote" | (string & {});
221
+ /**
222
+ * Batch metadata handed to `onNodesChange`/`onEdgesChange` alongside the changes.
223
+ * Every change in one batch shares an origin by construction.
224
+ */
225
+ type ChangeMeta = {
226
+ origin: ChangeOrigin;
227
+ };
228
+ /**
229
+ * A local gesture opening or closing on one or more nodes — a pointer drag, a
230
+ * resize, or a keyboard grab. In a shared document these are the acquire and
231
+ * release signals: take a lock on start, drop it on end.
232
+ *
233
+ * - `id` — the node the gesture began on (the one grabbed).
234
+ * - `ids` — every node moving with it, `id` included. A group drag carries the
235
+ * whole selection; a resize carries one node.
236
+ */
237
+ type NodeGestureEvent = {
238
+ id: NodeId;
239
+ ids: NodeId[];
240
+ };
241
+ /**
242
+ * A peer's lock arrived on a node this client is already holding — the double-
243
+ * hold a gesture guard alone can't rule out, since two clients can start a drag
244
+ * before either one's lock reaches the other.
245
+ *
246
+ * rivet reports it and keeps going: the running gesture stays authoritative
247
+ * over its geometry, because ripping a node out from under a live pointer is a
248
+ * worse outcome than a late merge, and only the consumer knows whether its
249
+ * transport made this client the loser. Yield with
250
+ * {@link RivetInstance.releaseNodeGesture}.
251
+ *
252
+ * - `nodeId` — the held node the incoming lock names.
253
+ * - `holderId` — the peer the lock table gives it to.
254
+ * - `ids` — every node the local gesture is holding, `nodeId` included, so a
255
+ * consumer that yields can release the whole group in one call.
256
+ */
257
+ type NodeLockConflictEvent = {
258
+ nodeId: NodeId;
259
+ holderId: string;
260
+ ids: NodeId[];
261
+ };
208
262
  /**
209
263
  * What a handle may be when dragging a connection:
210
264
  * - `"source"` — can only start an edge.
@@ -512,4 +566,4 @@ type RivetControls = {
512
566
  setViewport: (viewport: Viewport) => void;
513
567
  };
514
568
 
515
- export type { AlignmentGuide as A, EdgeMarker as B, Connection as C, DefaultEdgeOptions as D, EdgeChange as E, EdgePath as F, EdgePathParams as G, HandlePosition as H, EdgeStyle as I, FitViewOptions as J, NodeComponent as K, LaneChange as L, Swimlane as M, NodeId as N, PendingConnection as P, Rect as R, Size as S, Vec2 as V, RivetEdge as a, NodeChange as b, RivetNode as c, NodeProps as d, NodeTypes as e, HandleType as f, Viewport as g, EdgeTypes as h, EdgeRendererFactory as i, SwimlaneGroup as j, SwimlaneMargin as k, SwimlaneHeaderProps as l, SwimlaneLabelProps as m, SwimlaneSizeChange as n, RivetSelection as o, EdgeId as p, HandleRecord as q, ReconnectDelegate as r, RivetControls as s, RivetSnapshot as t, EdgeRenderer as u, EdgeRendererOptions as v, EdgeDrawExtras as w, EdgeEnd as x, EdgePathFn as y, CenterNodeOptions as z };
569
+ export type { AlignmentGuide as A, EdgeDrawExtras as B, Connection as C, DefaultEdgeOptions as D, EdgeChange as E, EdgeEnd as F, EdgePathFn as G, HandlePosition as H, CenterNodeOptions as I, EdgeMarker as J, EdgePath as K, LaneChange as L, EdgePathParams as M, NodeId as N, EdgeStyle as O, PendingConnection as P, FitViewOptions as Q, Rect as R, Size as S, NodeComponent as T, Swimlane as U, Vec2 as V, NodeChange as a, RivetEdge as b, RivetNode as c, NodeProps as d, NodeTypes as e, HandleType as f, ChangeOrigin as g, EdgeId as h, HandleRecord as i, ChangeMeta as j, Viewport as k, NodeGestureEvent as l, NodeLockConflictEvent as m, RivetSelection as n, ReconnectDelegate as o, EdgeTypes as p, EdgeRendererFactory as q, SwimlaneGroup as r, SwimlaneMargin as s, SwimlaneHeaderProps as t, SwimlaneLabelProps as u, SwimlaneSizeChange as v, RivetControls as w, RivetSnapshot as x, EdgeRenderer as y, EdgeRendererOptions as z };
@@ -0,0 +1,327 @@
1
+ import { N as NodeId, V as Vec2, R as Rect } from './types-C9Vn1rzy.js';
2
+
3
+ /**
4
+ * Locks — nodes a peer has claimed, and what this client may still do to them.
5
+ *
6
+ * A lock is policy where presence is description. `Peer.holding` says "they
7
+ * have their pointer on this"; a lock says "hands off", and rivet acts on it.
8
+ * The two render identically on purpose — to whoever is looking, they mean the
9
+ * same thing — but only a lock refuses anything.
10
+ *
11
+ * Locks come in whole, as the `lockedNodes` prop, because the transport already
12
+ * has to hold them: a lock outlives the tab that took it (that's the point of
13
+ * taking one), so the server owns the lifetime and rivet only reads it. Nothing
14
+ * here enters the graph, the change stream, or history — a lock is no more a
15
+ * fact about the document than a cursor is.
16
+ *
17
+ * What a lock refuses is a default, not a rule. {@link LOCK_DEFAULT_REFUSED} is
18
+ * rivet's opinion; `canInteractWithLocked` is the consumer's, and it wins on
19
+ * every intent it's asked about.
20
+ */
21
+
22
+ /**
23
+ * A thing this client might try to do to a locked node.
24
+ *
25
+ * Granular because the answer genuinely differs per app: a design tool wants a
26
+ * locked node inspectable, a form builder wants it untouchable, and a pipeline
27
+ * editor usually wants to keep wiring around one while a colleague renames it.
28
+ */
29
+ type LockIntent =
30
+ /** Click it, or sweep it up in a marquee. */
31
+ "select"
32
+ /** Move it — pointer drag, group drag, keyboard grab or arrow nudge. */
33
+ | "drag"
34
+ /** Change its box through {@link NodeResizer}. */
35
+ | "resize"
36
+ /** Remove it as part of deleting the selection. */
37
+ | "delete"
38
+ /** Start or land an edge on one of its handles. */
39
+ | "connect";
40
+ /** What {@link CanInteractWithLocked} is asked about. */
41
+ type LockInteraction = {
42
+ nodeId: NodeId;
43
+ /** The peer holding the lock — the `lockedNodes` value for this node. */
44
+ holderId: string;
45
+ intent: LockIntent;
46
+ };
47
+ /**
48
+ * Consumer override for rivet's default refusals, consulted once per attempt.
49
+ * Return `true` to allow the intent, `false` to refuse it — the default is
50
+ * replaced, not consulted, so a handler owns the whole policy.
51
+ */
52
+ type CanInteractWithLocked = (event: LockInteraction) => boolean;
53
+ /** A node's lock as {@link useNodeLock} reports it. */
54
+ type NodeLock = {
55
+ locked: boolean;
56
+ /** The peer holding it, or `undefined` when the node isn't locked. */
57
+ holderId?: string;
58
+ };
59
+ /**
60
+ * The intents rivet refuses on a locked node when no policy is given.
61
+ *
62
+ * `connect` is deliberately absent. A lock claims the node, not the graph
63
+ * around it: an edge is its own row with its own id, and refusing to wire one
64
+ * would make a locked node an island for as long as somebody holds it — a far
65
+ * heavier lock than "someone is editing this" warrants. Consumers who want that
66
+ * can say so through {@link CanInteractWithLocked}.
67
+ */
68
+ declare const LOCK_DEFAULT_REFUSED: ReadonlySet<LockIntent>;
69
+ /**
70
+ * The lock registry — one per graph, reachable as `store.locks`.
71
+ *
72
+ * Reads are hot: every marquee frame and every mover loop asks
73
+ * {@link LockRegistry.allows}, so it stays a map lookup plus at most one
74
+ * consumer call, with no allocation on the common (nothing locked) path.
75
+ */
76
+ type LockRegistry = {
77
+ /**
78
+ * Replace the lock table (the `lockedNodes` prop). Returns the nodes that
79
+ * weren't locked before and are now — the store scans those for conflicts
80
+ * with a live local gesture.
81
+ */
82
+ setLocks: (locks: Record<NodeId, string> | undefined) => NodeId[];
83
+ /** Install the consumer's policy, or `null` to fall back to the defaults. */
84
+ setPolicy: (policy: CanInteractWithLocked | null) => void;
85
+ isLocked: (id: NodeId) => boolean;
86
+ /** Who holds this node's lock, or `null` when it isn't locked. */
87
+ getHolder: (id: NodeId) => string | null;
88
+ /** The whole table. The renderer walks it once a frame. */
89
+ getLocks: () => ReadonlyMap<NodeId, string>;
90
+ /** How many nodes are locked — the render loop's early-out. */
91
+ size: () => number;
92
+ /**
93
+ * Whether this client may act on a node. Always `true` for an unlocked node,
94
+ * so callers can ask unconditionally.
95
+ */
96
+ allows: (id: NodeId, intent: LockIntent) => boolean;
97
+ /** Bumped on every table change, for `useSyncExternalStore`. */
98
+ getVersion: () => number;
99
+ /** Subscribe to the table changing — locks move at human rate, so this is React-safe. */
100
+ subscribe: (listener: () => void) => () => void;
101
+ };
102
+
103
+ /**
104
+ * Presence — who else is in the document, and where they are.
105
+ *
106
+ * Presence is ephemeral by construction. Nothing here enters the graph, the
107
+ * change stream, or history: a peer's cursor is not a fact about the document,
108
+ * it's a fact about a person, and it stops being true the moment they move. So
109
+ * the registry is imperative, painted straight from the render loop, and thrown
110
+ * away when the tab closes.
111
+ *
112
+ * Two doors write to it, at deliberately different speeds:
113
+ *
114
+ * - the `peers` prop — the roster. Identity (name, colour) and the slow
115
+ * fields (selection, holding). React-rate.
116
+ * - {@link PresenceRegistry.setPeerCursor} / `setPeerNodeTransform` — the hot
117
+ * path off your ephemeral channel, called as fast as frames arrive. These
118
+ * never touch React: they mark the canvas dirty and nothing else. A cursor
119
+ * dirties only the cursor layer; a transform moves a real node, so it
120
+ * dirties the frame.
121
+ *
122
+ * A peer the roster has never mentioned is still legitimate — a cursor frame
123
+ * can beat the join event, and dropping it would blink the cursor. Such a peer
124
+ * lives until its cursor clears (see {@link PresenceRegistry.setPeerCursor}),
125
+ * and its colour is derived from its id, so the record can be rebuilt any
126
+ * number of times without the colour ever flickering.
127
+ */
128
+
129
+ /**
130
+ * A participant other than this client, as your transport describes them.
131
+ *
132
+ * Everything is optional but `id`: presence degrades field by field, and a peer
133
+ * you know nothing about except that they exist is still worth an avatar.
134
+ */
135
+ type Peer = {
136
+ id: string;
137
+ /** Display name, drawn beside their cursor. Unnamed peers get no label. */
138
+ name?: string;
139
+ /** Any CSS colour. Defaults to a stable colour derived from {@link Peer.id}. */
140
+ color?: string;
141
+ /**
142
+ * Cursor in **world** coordinates — peers pan and zoom independently, so a
143
+ * screen point means nothing to anyone else. `null` when their pointer is
144
+ * off the canvas. Only a seed: once {@link PresenceRegistry.setPeerCursor}
145
+ * has been called for a peer, the imperative value owns the cursor.
146
+ */
147
+ cursor?: Vec2 | null;
148
+ /** Nodes they have selected. Outlined in their colour. */
149
+ selection?: NodeId[];
150
+ /** Nodes under one of their live gestures (their `onNodeDragStart`). */
151
+ holding?: NodeId[];
152
+ };
153
+ /**
154
+ * A peer as the registry holds it: identity resolved, live overlays merged in.
155
+ * What the renderer and {@link usePeers} both read.
156
+ */
157
+ type PeerRecord = {
158
+ id: string;
159
+ name?: string;
160
+ /** Always set — {@link peerColor} fills in for a peer that declared none. */
161
+ color: string;
162
+ cursor: Vec2 | null;
163
+ selection: readonly NodeId[];
164
+ holding: readonly NodeId[];
165
+ /**
166
+ * Where this peer currently has each node they're dragging, in world space —
167
+ * the frame-rate overlay from `setPeerNodeTransform`. The node renders there
168
+ * until they let go; the graph keeps its own position until their commit
169
+ * arrives as a real change.
170
+ */
171
+ transforms: ReadonlyMap<NodeId, Rect>;
172
+ };
173
+ /** Presence policy, set via the `presenceOptions` prop. */
174
+ type PresenceOptions = {
175
+ /**
176
+ * Smallest gap between outbound `onLocalPresence` snapshots, in ms. Cursor
177
+ * movement is coalesced to this rate; selection and gesture changes ignore it
178
+ * and go out at once (a lock signal that waits is a lock signal that races).
179
+ * Default `50`.
180
+ */
181
+ throttleMs?: number;
182
+ /**
183
+ * Give peer cursors their own canvas layer. Turn off to render your own from
184
+ * {@link usePeers} — avatars, follow buttons, anything the canvas can't do —
185
+ * and the layer is never created at all. Node outlines and peers' live boxes
186
+ * are unaffected: they live with the geometry they annotate. Default `true`.
187
+ */
188
+ renderCursors?: boolean;
189
+ };
190
+ type ResolvedPresenceOptions = Required<PresenceOptions>;
191
+ declare const DEFAULT_PRESENCE_OPTIONS: ResolvedPresenceOptions;
192
+ /**
193
+ * Default peer colours. Spaced around the wheel and mid-toned, so they read on
194
+ * a light or dark canvas and stay distinguishable side by side.
195
+ */
196
+ declare const PEER_COLORS: readonly ["#6366f1", "#ec4899", "#f59e0b", "#10b981", "#3b82f6", "#8b5cf6", "#ef4444", "#14b8a6"];
197
+ /**
198
+ * A stable colour for a peer id. Deterministic, so the same person is the same
199
+ * colour on every client and across a record being pruned and rebuilt — nobody
200
+ * coordinates a palette, and nobody's cursor changes colour mid-session.
201
+ */
202
+ declare function peerColor(id: string): string;
203
+ /**
204
+ * The presence registry — one per graph, reachable as `store.presence`.
205
+ *
206
+ * Reads are snapshot-based: {@link getPeers} returns a cached array rebuilt
207
+ * only when something actually changed, so the render loop can call it every
208
+ * frame and `useSyncExternalStore` can compare identities.
209
+ */
210
+ type PresenceRegistry = {
211
+ /**
212
+ * Replace the roster (the `peers` prop). Peers that were in the previous
213
+ * roster and aren't in this one are dropped outright — leaving the roster is
214
+ * how a peer leaves. Peers this registry only knows imperatively are kept.
215
+ */
216
+ setPeers: (peers: Peer[]) => void;
217
+ /**
218
+ * Move a peer's cursor, in world coordinates. The hot path: call it as fast
219
+ * as your channel delivers frames — it dirties the cursor layer and nothing
220
+ * else, so the cost doesn't scale into the rest of the canvas.
221
+ *
222
+ * `null` parks the cursor. For a peer the roster never declared, that leaves
223
+ * nothing to draw, so the record is dropped — the next frame recreates it
224
+ * with the same colour.
225
+ */
226
+ setPeerCursor: (peerId: string, point: Vec2 | null) => void;
227
+ /**
228
+ * Where a peer currently has a node, in world space — their drag, mid-flight.
229
+ * The node itself moves there: it's painted, hit-tested and wired at the peer's
230
+ * live box, while the graph underneath is untouched until their commit lands.
231
+ * `null` releases it, which is what their drop should do just before their
232
+ * real change arrives.
233
+ */
234
+ setPeerNodeTransform: (peerId: string, nodeId: NodeId, rect: Rect | null) => void;
235
+ /**
236
+ * Every node a peer currently has under a gesture, at its *rendered* position
237
+ * — the interpolated one, not the last frame that arrived. What the store
238
+ * folds into world positions.
239
+ */
240
+ getNodeTransforms: () => ReadonlyMap<NodeId, Rect>;
241
+ /**
242
+ * Bumped whenever a rendered transform moves. Lets the store cache world
243
+ * positions across frames where peers are idle.
244
+ */
245
+ getTransformVersion: () => number;
246
+ /**
247
+ * Advance the interpolation toward the values that arrived, and report
248
+ * whether anything is still in motion (i.e. whether another frame is owed).
249
+ *
250
+ * Frames arrive at whatever rate a transport manages — 20Hz is normal, and
251
+ * stepping straight to each one makes a cursor stutter and a dragged node
252
+ * jump. Easing between them costs one lerp per peer and buys motion that
253
+ * reads as somebody moving rather than as packets landing.
254
+ */
255
+ step: (now: number) => boolean;
256
+ /** Forget a peer entirely — cursor, held nodes and all. Use it when they disconnect. */
257
+ removePeer: (peerId: string) => void;
258
+ /** Every known peer. A cached snapshot; identity only changes when the data does. */
259
+ getPeers: () => readonly PeerRecord[];
260
+ /** True when nobody else is here — the render loop's early-out. */
261
+ isEmpty: () => boolean;
262
+ /**
263
+ * Subscribe to **roster** changes: peers arriving or leaving, and their
264
+ * identity, selection or holding changing. Cursor and transform frames
265
+ * deliberately don't notify — they'd re-render React at pointer rate, and
266
+ * they're already painted on the canvas. Read them from {@link getPeers} on
267
+ * the frame channel if you need them in the DOM.
268
+ */
269
+ subscribe: (listener: () => void) => () => void;
270
+ };
271
+
272
+ /**
273
+ * The outbound half of presence: what this client tells everyone else.
274
+ *
275
+ * It publishes a *snapshot*, never an event stream. A snapshot is idempotent —
276
+ * a dropped one costs nothing, a duplicate costs nothing, and a client that
277
+ * reconnects mid-session is immediately correct rather than replaying history.
278
+ * That's also how presence channels themselves model state, so this maps onto
279
+ * any transport without a translation layer.
280
+ *
281
+ * Cursor movement is coalesced to `throttleMs`; selection and gesture changes
282
+ * jump the queue, because they're the signals a peer acts on (a lock request
283
+ * that waits 50ms is a lock request that can lose a race it should have won).
284
+ */
285
+
286
+ /** This client's presence, as handed to `onLocalPresence`. */
287
+ type LocalPresence = {
288
+ /**
289
+ * Pointer position in **world** coordinates, or `null` when the pointer is
290
+ * off the canvas. World, not screen: peers have their own viewports, and a
291
+ * screen point means nothing on the other side.
292
+ */
293
+ cursor: Vec2 | null;
294
+ /** Currently selected node ids. */
295
+ selection: NodeId[];
296
+ /** Nodes under a live local gesture — the same set as `onNodeDragStart`. */
297
+ holding: NodeId[];
298
+ };
299
+
300
+ /**
301
+ * Whether a node is locked, and by whom — for the chrome rivet can't draw for
302
+ * you: a padlock in the node's header, a "Ada is editing" line, a disabled
303
+ * form inside the node body.
304
+ *
305
+ * rivet already refuses the interactions it owns (see the `lockedNodes` prop),
306
+ * so this is for *your* controls. Re-renders only when this node changes hands,
307
+ * not when any lock in the document moves.
308
+ *
309
+ * ```tsx
310
+ * function MyNode({ id }: NodeProps) {
311
+ * const { locked, holderId } = useNodeLock(id)
312
+ * return <input disabled={locked} title={locked ? `${holderId} is editing` : ""} />
313
+ * }
314
+ * ```
315
+ */
316
+ declare function useNodeLock(id: NodeId): NodeLock;
317
+ /**
318
+ * Whether this client may do `intent` to a node right now — the same question
319
+ * every rivet input path asks, answered through the same policy.
320
+ *
321
+ * Use it to keep your own affordances honest: hide a delete button on a node a
322
+ * peer has locked, rather than letting it be pressed and refused. Always `true`
323
+ * for an unlocked node.
324
+ */
325
+ declare function useNodeLockAllows(id: NodeId, intent: LockIntent): boolean;
326
+
327
+ export { type CanInteractWithLocked as C, DEFAULT_PRESENCE_OPTIONS as D, LOCK_DEFAULT_REFUSED as L, type NodeLock as N, type PeerRecord as P, type ResolvedPresenceOptions as R, type LocalPresence as a, type LockIntent as b, type LockInteraction as c, type LockRegistry as d, PEER_COLORS as e, type Peer as f, type PresenceOptions as g, type PresenceRegistry as h, useNodeLockAllows as i, peerColor as p, useNodeLock as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolosal-ai/rivet",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Canvas-rendered node graph for React: DOM nodes, Canvas2D edges, swimlanes, anchors, undo/redo",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,6 +31,10 @@
31
31
  "types": "./dist/anchor/index.d.ts",
32
32
  "import": "./dist/anchor/index.js"
33
33
  },
34
+ "./presence": {
35
+ "types": "./dist/presence/index.d.ts",
36
+ "import": "./dist/presence/index.js"
37
+ },
34
38
  "./svg": {
35
39
  "types": "./dist/svg/index.d.ts",
36
40
  "import": "./dist/svg/index.js"