live-cursors 0.1.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.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/dist/broadcast.d.mts +13 -0
- package/dist/broadcast.mjs +24 -0
- package/dist/color-CN1S_Vq9.mjs +27 -0
- package/dist/cursors-BhKZnRWg.mjs +348 -0
- package/dist/cursors-CyiNtLCv.d.mts +107 -0
- package/dist/dom.d.mts +20 -0
- package/dist/dom.mjs +97 -0
- package/dist/echo.d.mts +26 -0
- package/dist/echo.mjs +24 -0
- package/dist/index-4O7Ji1vk.d.mts +40 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +3 -0
- package/dist/react.d.mts +26 -0
- package/dist/react.mjs +91 -0
- package/dist/vue.d.mts +60 -0
- package/dist/vue.mjs +144 -0
- package/dist/websocket.d.mts +20 -0
- package/dist/websocket.mjs +75 -0
- package/package.json +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 live-cursors contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# live-cursors
|
|
2
|
+
|
|
3
|
+
Multiplayer cursors for **any** transport. ~3 KB, zero dependencies, no SaaS.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
Works over Laravel Echo/Reverb, a raw WebSocket, `BroadcastChannel`, Supabase Realtime — or a channel you already have. Ships vanilla, Vue and React bindings.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i live-cursors
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Why another cursors package
|
|
14
|
+
|
|
15
|
+
Existing options ask you to adopt their world: Liveblocks, Velt and Ably Spaces are hosted services, `y-presence` needs Yjs, `cursor-party` needs PartyKit, `nuxt-live-cursors` needs Nitro WebSockets. [`perfect-cursors`](https://github.com/steveruizok/perfect-cursors) stays out of your way but only interpolates positions — everything else is yours to write.
|
|
16
|
+
|
|
17
|
+
This package is the everything else: throttling, TTL, coordinate spaces, stable colors, rendering, teardown. Bring your own channel.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
### Vanilla
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createCursors } from "live-cursors";
|
|
25
|
+
import { broadcastTransport } from "live-cursors/broadcast";
|
|
26
|
+
import { renderCursors } from "live-cursors/dom";
|
|
27
|
+
|
|
28
|
+
const cursors = createCursors({
|
|
29
|
+
transport: broadcastTransport({ room: "demo" }),
|
|
30
|
+
selfId: currentUser.id,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
cursors.attach();
|
|
34
|
+
renderCursors({ cursors, label: cursor => names[cursor.id] });
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`broadcastTransport` needs no server at all — open two tabs and you have multiplayer cursors.
|
|
38
|
+
|
|
39
|
+
### Vue
|
|
40
|
+
|
|
41
|
+
```vue
|
|
42
|
+
<script setup lang="ts">
|
|
43
|
+
import { LiveCursors, useCursors } from "live-cursors/vue";
|
|
44
|
+
import { websocketTransport } from "live-cursors/websocket";
|
|
45
|
+
|
|
46
|
+
const board = useTemplateRef("board");
|
|
47
|
+
const { cursors } = useCursors({
|
|
48
|
+
transport: () => websocketTransport({ url: "wss://example.com/cursors", room: "board-1" }),
|
|
49
|
+
selfId: user.id,
|
|
50
|
+
container: board,
|
|
51
|
+
});
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<div ref="board" class="relative overflow-auto">
|
|
56
|
+
<!-- board content -->
|
|
57
|
+
<LiveCursors :cursors="cursors" :label="cursor => names[cursor.id]" />
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The transport is a factory so nothing touches `WebSocket` during SSR.
|
|
63
|
+
|
|
64
|
+
### React
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { LiveCursors, useCursors } from "live-cursors/react";
|
|
68
|
+
import { websocketTransport } from "live-cursors/websocket";
|
|
69
|
+
|
|
70
|
+
function Board() {
|
|
71
|
+
const [board, setBoard] = useState<HTMLDivElement | null>(null);
|
|
72
|
+
const { cursors } = useCursors({
|
|
73
|
+
transport: () => websocketTransport({ url: "wss://example.com/cursors" }),
|
|
74
|
+
selfId: user.id,
|
|
75
|
+
container: board,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div ref={setBoard} style={{ position: "relative", overflow: "auto" }}>
|
|
80
|
+
<LiveCursors cursors={cursors} label={cursor => names[cursor.id]} />
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Custom label, built-in arrow
|
|
87
|
+
|
|
88
|
+
The `label` prop takes a string; for richer content use the `label` slot (Vue) or `renderLabel`
|
|
89
|
+
(React) — the arrow stays, only the pill is yours. The `default` slot / `children` replaces the
|
|
90
|
+
whole cursor instead.
|
|
91
|
+
|
|
92
|
+
```vue
|
|
93
|
+
<LiveCursors :cursors="cursors">
|
|
94
|
+
<template #label="{ cursor }">
|
|
95
|
+
<UAvatar :src="roster[cursor.id].avatar" size="3xs" />
|
|
96
|
+
<span>{{ roster[cursor.id].name }}</span>
|
|
97
|
+
</template>
|
|
98
|
+
</LiveCursors>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Presence: dropping a cursor immediately
|
|
102
|
+
|
|
103
|
+
`ttlMs` covers clients that vanish silently. When your presence layer reports that someone
|
|
104
|
+
left, drop their cursor at once:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
channel.leaving(user => cursors.remove(user.id));
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Laravel Echo / Reverb
|
|
111
|
+
|
|
112
|
+
Cursor updates ride on client events, so the channel has to be private or presence.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { createCursors } from "live-cursors";
|
|
116
|
+
import { echoTransport } from "live-cursors/echo";
|
|
117
|
+
|
|
118
|
+
const channel = window.Echo.private(`App.Models.Board.${boardId}`);
|
|
119
|
+
|
|
120
|
+
const cursors = createCursors({
|
|
121
|
+
transport: echoTransport({ channel }),
|
|
122
|
+
selfId: user.id,
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Coordinate spaces
|
|
127
|
+
|
|
128
|
+
Pick the one that matches how participants move through your content:
|
|
129
|
+
|
|
130
|
+
| Space | Behaviour | Use for |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `viewportSpace()` (default) | `x / innerWidth` — a cursor sits at the same screen position for everyone | landing pages, presentations, fixed layouts |
|
|
133
|
+
| `elementSpace(el)` | normalized against the element's scrollable content | boards, canvases, long documents where participants scroll independently |
|
|
134
|
+
| `customSpace({ ... })` | your own mapping | zoomable canvases, virtualized lists |
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { createCursors, elementSpace } from "live-cursors";
|
|
138
|
+
|
|
139
|
+
const cursors = createCursors({
|
|
140
|
+
transport,
|
|
141
|
+
selfId: user.id,
|
|
142
|
+
space: elementSpace(() => boardRef.value),
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Options
|
|
147
|
+
|
|
148
|
+
| Option | Default | What it does |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `transport` | — | where messages go; see below |
|
|
151
|
+
| `selfId` | — | local participant id; incoming messages carrying it are ignored |
|
|
152
|
+
| `space` | `viewportSpace()` | coordinate mapping |
|
|
153
|
+
| `throttleMs` | `40` | ~25 outgoing updates per second, leading + trailing |
|
|
154
|
+
| `ttlMs` | `3000` | a cursor with no update in this window disappears |
|
|
155
|
+
| `maxRemote` | `50` | cap on tracked participants; a flooding client cannot grow the map |
|
|
156
|
+
| `enabled` | `true` | set to `false` to stop broadcasting (a "hide my cursor" toggle) |
|
|
157
|
+
| `meta` | — | tiny extra payload per move; keep personal data out of it |
|
|
158
|
+
|
|
159
|
+
## Writing a transport
|
|
160
|
+
|
|
161
|
+
Three methods, no base class:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import type { CursorTransport } from "live-cursors";
|
|
165
|
+
|
|
166
|
+
export function myTransport(): CursorTransport {
|
|
167
|
+
return {
|
|
168
|
+
send: message => socket.emit("cursor", message),
|
|
169
|
+
onMessage: (handler) => {
|
|
170
|
+
const listener = (payload, senderId) => handler(payload, senderId);
|
|
171
|
+
socket.on("cursor", listener);
|
|
172
|
+
return () => socket.off("cursor", listener);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
If your transport already knows who sent a message, pass its `senderId` as the second argument — it wins over the id inside the payload, so a client cannot claim someone else's identity.
|
|
179
|
+
|
|
180
|
+
## Notes
|
|
181
|
+
|
|
182
|
+
- **No personal data on the wire.** A cursor carries an id and two numbers. Names and avatars come from whatever roster your app already has — that keeps payloads small at 25 fps and keeps cursor traffic out of your privacy surface.
|
|
183
|
+
- **Nothing is persisted.** The library holds cursors in memory and forgets them after `ttlMs`.
|
|
184
|
+
- **Touch input is ignored.** There is no hover on touch devices, so nothing is broadcast from them.
|
|
185
|
+
- **Respects `prefers-reduced-motion`** — the interpolating transition is dropped.
|
|
186
|
+
- **Colors and labels stay data.** The tint travels through the `--lc-color` custom property and
|
|
187
|
+
label text is set as text, so `color` and `label` callbacks cannot introduce markup.
|
|
188
|
+
- **ESM only.** Node 18+, any modern bundler.
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pnpm install
|
|
194
|
+
pnpm test # vitest, happy-dom
|
|
195
|
+
pnpm test:e2e # playwright: two browser contexts through a websocket relay
|
|
196
|
+
pnpm typecheck
|
|
197
|
+
pnpm lint
|
|
198
|
+
pnpm play # playground: viewport, element and websocket demos
|
|
199
|
+
pnpm check:pkg # build + publint + are-the-types-wrong
|
|
200
|
+
pnpm demo:gif # re-record docs/demo.gif (needs ffmpeg on PATH)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The gif is produced by [`scripts/record-demo.mjs`](scripts/record-demo.mjs): it starts the relay
|
|
204
|
+
and the playground, drives two participants along scripted pointer paths, films a third context
|
|
205
|
+
and encodes the result — so the demo can be regenerated whenever the cursors change shape.
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { a as CursorTransport } from "./cursors-CyiNtLCv.mjs";
|
|
2
|
+
//#region src/transports/broadcast.d.ts
|
|
3
|
+
interface BroadcastTransportOptions {
|
|
4
|
+
/** channel name; participants sharing it see each other */
|
|
5
|
+
room?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* BroadcastChannel transport — same-origin tabs only, no server at all.
|
|
9
|
+
* Useful for demos and for developing against cursors without infrastructure.
|
|
10
|
+
*/
|
|
11
|
+
declare function broadcastTransport(options?: BroadcastTransportOptions): CursorTransport;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { BroadcastTransportOptions, broadcastTransport };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/transports/broadcast.ts
|
|
2
|
+
/**
|
|
3
|
+
* BroadcastChannel transport — same-origin tabs only, no server at all.
|
|
4
|
+
* Useful for demos and for developing against cursors without infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
function broadcastTransport(options = {}) {
|
|
7
|
+
const { room = "live-cursors" } = options;
|
|
8
|
+
const channel = new BroadcastChannel(room);
|
|
9
|
+
return {
|
|
10
|
+
send(message) {
|
|
11
|
+
channel.postMessage(message);
|
|
12
|
+
},
|
|
13
|
+
onMessage(handler) {
|
|
14
|
+
const listener = (event) => handler(event.data);
|
|
15
|
+
channel.addEventListener("message", listener);
|
|
16
|
+
return () => channel.removeEventListener("message", listener);
|
|
17
|
+
},
|
|
18
|
+
close() {
|
|
19
|
+
channel.close();
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { broadcastTransport };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/color.ts
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic hue per id: the same participant keeps the same color across
|
|
4
|
+
* reloads and across clients without any color negotiation.
|
|
5
|
+
*/
|
|
6
|
+
function hashHue(id) {
|
|
7
|
+
let hash = 0;
|
|
8
|
+
for (let i = 0; i < id.length; i++) {
|
|
9
|
+
hash = id.charCodeAt(i) + ((hash << 5) - hash);
|
|
10
|
+
hash |= 0;
|
|
11
|
+
}
|
|
12
|
+
return Math.abs(hash) % 360;
|
|
13
|
+
}
|
|
14
|
+
function cursorColor(id, options = {}) {
|
|
15
|
+
const { saturation = 70, lightness = 55 } = options;
|
|
16
|
+
return `hsl(${hashHue(id)}, ${saturation}%, ${lightness}%)`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Picks from a fixed palette instead of the full hue circle — for brand palettes
|
|
20
|
+
* or when hues have to stay distinguishable for color-blind viewers.
|
|
21
|
+
*/
|
|
22
|
+
function paletteColor(id, palette) {
|
|
23
|
+
if (palette.length === 0) return cursorColor(id);
|
|
24
|
+
return palette[hashHue(id) % palette.length];
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { hashHue as n, paletteColor as r, cursorColor as t };
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
//#region src/protocol.ts
|
|
2
|
+
const PROTOCOL_VERSION = 1;
|
|
3
|
+
function clamp01(value) {
|
|
4
|
+
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Turns wire data into a message, or null when it is unusable.
|
|
8
|
+
* Coordinates arrive from other clients, so they are clamped rather than trusted.
|
|
9
|
+
*/
|
|
10
|
+
function parseMessage(raw) {
|
|
11
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
12
|
+
const data = raw;
|
|
13
|
+
if (data.v !== 1) return null;
|
|
14
|
+
const id = typeof data.id === "string" && data.id !== "" ? data.id : void 0;
|
|
15
|
+
if (data.t === "l") return id === void 0 ? {
|
|
16
|
+
v: 1,
|
|
17
|
+
t: "l"
|
|
18
|
+
} : {
|
|
19
|
+
v: 1,
|
|
20
|
+
t: "l",
|
|
21
|
+
id
|
|
22
|
+
};
|
|
23
|
+
if (data.t !== "m") return null;
|
|
24
|
+
if (typeof data.x !== "number" || typeof data.y !== "number") return null;
|
|
25
|
+
if (!Number.isFinite(data.x) || !Number.isFinite(data.y)) return null;
|
|
26
|
+
const message = {
|
|
27
|
+
v: 1,
|
|
28
|
+
t: "m",
|
|
29
|
+
x: clamp01(data.x),
|
|
30
|
+
y: clamp01(data.y)
|
|
31
|
+
};
|
|
32
|
+
if (id !== void 0) message.id = id;
|
|
33
|
+
if (typeof data.m === "object" && data.m !== null) message.m = data.m;
|
|
34
|
+
return message;
|
|
35
|
+
}
|
|
36
|
+
function moveMessage(id, x, y, meta) {
|
|
37
|
+
const message = {
|
|
38
|
+
v: 1,
|
|
39
|
+
t: "m",
|
|
40
|
+
id,
|
|
41
|
+
x: clamp01(x),
|
|
42
|
+
y: clamp01(y)
|
|
43
|
+
};
|
|
44
|
+
if (meta !== void 0) message.m = meta;
|
|
45
|
+
return message;
|
|
46
|
+
}
|
|
47
|
+
function leaveMessage(id) {
|
|
48
|
+
return {
|
|
49
|
+
v: 1,
|
|
50
|
+
t: "l",
|
|
51
|
+
id
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/space.ts
|
|
56
|
+
function viewportSpace() {
|
|
57
|
+
return {
|
|
58
|
+
toNormalized(pointer) {
|
|
59
|
+
const width = window.innerWidth;
|
|
60
|
+
const height = window.innerHeight;
|
|
61
|
+
if (width === 0 || height === 0) return null;
|
|
62
|
+
return {
|
|
63
|
+
x: pointer.clientX / width,
|
|
64
|
+
y: pointer.clientY / height
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
fromNormalized(point) {
|
|
68
|
+
return {
|
|
69
|
+
x: point.x * window.innerWidth,
|
|
70
|
+
y: point.y * window.innerHeight
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
size() {
|
|
74
|
+
return {
|
|
75
|
+
width: window.innerWidth,
|
|
76
|
+
height: window.innerHeight
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
target() {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Scroll-aware space: coordinates are relative to the scrollable content of an element,
|
|
86
|
+
* so a cursor stays on the content it points at even when participants scroll differently.
|
|
87
|
+
*/
|
|
88
|
+
function elementSpace(resolve) {
|
|
89
|
+
const target = () => typeof resolve === "function" ? resolve() : resolve;
|
|
90
|
+
return {
|
|
91
|
+
toNormalized(pointer) {
|
|
92
|
+
const el = target();
|
|
93
|
+
if (!el) return null;
|
|
94
|
+
const rect = el.getBoundingClientRect();
|
|
95
|
+
const width = el.scrollWidth;
|
|
96
|
+
const height = el.scrollHeight;
|
|
97
|
+
if (width === 0 || height === 0) return null;
|
|
98
|
+
const x = (pointer.clientX - rect.left + el.scrollLeft) / width;
|
|
99
|
+
const y = (pointer.clientY - rect.top + el.scrollTop) / height;
|
|
100
|
+
if (x < 0 || x > 1 || y < 0 || y > 1) return null;
|
|
101
|
+
return {
|
|
102
|
+
x,
|
|
103
|
+
y
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
fromNormalized(point) {
|
|
107
|
+
const el = target();
|
|
108
|
+
if (!el) return {
|
|
109
|
+
x: 0,
|
|
110
|
+
y: 0
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
x: point.x * el.scrollWidth,
|
|
114
|
+
y: point.y * el.scrollHeight
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
size() {
|
|
118
|
+
const el = target();
|
|
119
|
+
if (!el) return {
|
|
120
|
+
width: 0,
|
|
121
|
+
height: 0
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
width: el.scrollWidth,
|
|
125
|
+
height: el.scrollHeight
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
target
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function customSpace(space) {
|
|
132
|
+
return space;
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/throttle.ts
|
|
136
|
+
/**
|
|
137
|
+
* Leading + trailing throttle: the first move goes out immediately and the last
|
|
138
|
+
* one is never dropped, which is what keeps a cursor from freezing mid-screen.
|
|
139
|
+
*/
|
|
140
|
+
function throttle(fn, ms) {
|
|
141
|
+
let last = 0;
|
|
142
|
+
let timer = null;
|
|
143
|
+
let pending = null;
|
|
144
|
+
const run = (args) => {
|
|
145
|
+
last = Date.now();
|
|
146
|
+
pending = null;
|
|
147
|
+
fn(...args);
|
|
148
|
+
};
|
|
149
|
+
const throttled = ((...args) => {
|
|
150
|
+
const elapsed = Date.now() - last;
|
|
151
|
+
if (elapsed >= ms) {
|
|
152
|
+
if (timer) {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
timer = null;
|
|
155
|
+
}
|
|
156
|
+
run(args);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
pending = args;
|
|
160
|
+
if (timer) return;
|
|
161
|
+
timer = setTimeout(() => {
|
|
162
|
+
timer = null;
|
|
163
|
+
if (pending) run(pending);
|
|
164
|
+
}, ms - elapsed);
|
|
165
|
+
});
|
|
166
|
+
throttled.flush = () => {
|
|
167
|
+
if (timer) {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
timer = null;
|
|
170
|
+
}
|
|
171
|
+
if (pending) run(pending);
|
|
172
|
+
};
|
|
173
|
+
throttled.cancel = () => {
|
|
174
|
+
if (timer) {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
timer = null;
|
|
177
|
+
}
|
|
178
|
+
pending = null;
|
|
179
|
+
};
|
|
180
|
+
return throttled;
|
|
181
|
+
}
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/cursors.ts
|
|
184
|
+
const EMPTY = Object.freeze([]);
|
|
185
|
+
function createCursors(options) {
|
|
186
|
+
const { transport, selfId, space = viewportSpace(), throttleMs = 40, ttlMs = 3e3, sweepMs = 1e3, maxRemote = 50, meta, onError } = options;
|
|
187
|
+
const remote = /* @__PURE__ */ new Map();
|
|
188
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
189
|
+
let enabled = options.enabled ?? true;
|
|
190
|
+
let snapshot = EMPTY;
|
|
191
|
+
let snapshotDirty = false;
|
|
192
|
+
let sweepTimer = null;
|
|
193
|
+
let detachPointer = null;
|
|
194
|
+
let destroyed = false;
|
|
195
|
+
const fail = (error) => {
|
|
196
|
+
if (onError) onError(error);
|
|
197
|
+
else console.error("[live-cursors]", error);
|
|
198
|
+
};
|
|
199
|
+
const notify = () => {
|
|
200
|
+
snapshotDirty = true;
|
|
201
|
+
for (const listener of listeners) try {
|
|
202
|
+
listener();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
fail(error);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const getSnapshot = () => {
|
|
208
|
+
if (snapshotDirty) {
|
|
209
|
+
snapshot = remote.size === 0 ? EMPTY : Object.freeze([...remote.values()]);
|
|
210
|
+
snapshotDirty = false;
|
|
211
|
+
}
|
|
212
|
+
return snapshot;
|
|
213
|
+
};
|
|
214
|
+
const stopSweep = () => {
|
|
215
|
+
if (sweepTimer) {
|
|
216
|
+
clearInterval(sweepTimer);
|
|
217
|
+
sweepTimer = null;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const sweep = () => {
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
let changed = false;
|
|
223
|
+
for (const [id, cursor] of remote) if (now - cursor.updatedAt >= ttlMs) {
|
|
224
|
+
remote.delete(id);
|
|
225
|
+
changed = true;
|
|
226
|
+
}
|
|
227
|
+
if (remote.size === 0) stopSweep();
|
|
228
|
+
if (changed) notify();
|
|
229
|
+
};
|
|
230
|
+
const startSweep = () => {
|
|
231
|
+
if (sweepTimer || ttlMs <= 0) return;
|
|
232
|
+
sweepTimer = setInterval(sweep, sweepMs);
|
|
233
|
+
if (typeof sweepTimer === "object" && "unref" in sweepTimer) sweepTimer.unref?.();
|
|
234
|
+
};
|
|
235
|
+
const remove = (id) => {
|
|
236
|
+
if (!remote.delete(id)) return;
|
|
237
|
+
if (remote.size === 0) stopSweep();
|
|
238
|
+
notify();
|
|
239
|
+
};
|
|
240
|
+
const ingest = (raw, senderId) => {
|
|
241
|
+
if (destroyed) return;
|
|
242
|
+
const message = parseMessage(raw);
|
|
243
|
+
if (!message) return;
|
|
244
|
+
const id = senderId ?? message.id;
|
|
245
|
+
if (!id || id === selfId) return;
|
|
246
|
+
if (message.t === "l") {
|
|
247
|
+
remove(id);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (!remote.has(id) && remote.size >= maxRemote) return;
|
|
251
|
+
const cursor = {
|
|
252
|
+
id,
|
|
253
|
+
x: message.x,
|
|
254
|
+
y: message.y,
|
|
255
|
+
updatedAt: Date.now()
|
|
256
|
+
};
|
|
257
|
+
if (message.m !== void 0) cursor.meta = message.m;
|
|
258
|
+
remote.set(id, cursor);
|
|
259
|
+
startSweep();
|
|
260
|
+
notify();
|
|
261
|
+
};
|
|
262
|
+
const send = (x, y) => {
|
|
263
|
+
try {
|
|
264
|
+
transport.send(moveMessage(selfId, x, y, meta?.()));
|
|
265
|
+
} catch (error) {
|
|
266
|
+
fail(error);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
const sendThrottled = throttle(send, throttleMs);
|
|
270
|
+
const leave = () => {
|
|
271
|
+
sendThrottled.cancel();
|
|
272
|
+
try {
|
|
273
|
+
transport.send(leaveMessage(selfId));
|
|
274
|
+
} catch (error) {
|
|
275
|
+
fail(error);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const unsubscribeTransport = transport.onMessage(ingest);
|
|
279
|
+
const attach = (element) => {
|
|
280
|
+
detachPointer?.();
|
|
281
|
+
if (typeof window === "undefined") return () => {};
|
|
282
|
+
const host = element ?? space.target() ?? window;
|
|
283
|
+
const onPointerMove = (event) => {
|
|
284
|
+
if (!enabled) return;
|
|
285
|
+
const pointer = event;
|
|
286
|
+
if (pointer.pointerType && pointer.pointerType !== "mouse") return;
|
|
287
|
+
const point = space.toNormalized(pointer);
|
|
288
|
+
if (!point) return;
|
|
289
|
+
sendThrottled(point.x, point.y);
|
|
290
|
+
};
|
|
291
|
+
const onPointerOut = () => {
|
|
292
|
+
if (enabled) leave();
|
|
293
|
+
};
|
|
294
|
+
const onVisibility = () => {
|
|
295
|
+
if (document.visibilityState === "hidden") onPointerOut();
|
|
296
|
+
};
|
|
297
|
+
host.addEventListener("pointermove", onPointerMove, { passive: true });
|
|
298
|
+
host.addEventListener("pointerleave", onPointerOut, { passive: true });
|
|
299
|
+
window.addEventListener("blur", onPointerOut, { passive: true });
|
|
300
|
+
document.addEventListener("visibilitychange", onVisibility, { passive: true });
|
|
301
|
+
detachPointer = () => {
|
|
302
|
+
host.removeEventListener("pointermove", onPointerMove);
|
|
303
|
+
host.removeEventListener("pointerleave", onPointerOut);
|
|
304
|
+
window.removeEventListener("blur", onPointerOut);
|
|
305
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
306
|
+
detachPointer = null;
|
|
307
|
+
};
|
|
308
|
+
return () => detachPointer?.();
|
|
309
|
+
};
|
|
310
|
+
const detach = () => {
|
|
311
|
+
sendThrottled.cancel();
|
|
312
|
+
detachPointer?.();
|
|
313
|
+
};
|
|
314
|
+
return {
|
|
315
|
+
attach,
|
|
316
|
+
detach,
|
|
317
|
+
subscribe(listener) {
|
|
318
|
+
listeners.add(listener);
|
|
319
|
+
return () => listeners.delete(listener);
|
|
320
|
+
},
|
|
321
|
+
getSnapshot,
|
|
322
|
+
get: (id) => remote.get(id),
|
|
323
|
+
remove,
|
|
324
|
+
ingest,
|
|
325
|
+
setEnabled(next) {
|
|
326
|
+
if (enabled === next) return;
|
|
327
|
+
enabled = next;
|
|
328
|
+
if (!next) leave();
|
|
329
|
+
},
|
|
330
|
+
isEnabled: () => enabled,
|
|
331
|
+
leave,
|
|
332
|
+
space,
|
|
333
|
+
destroy() {
|
|
334
|
+
if (destroyed) return;
|
|
335
|
+
destroyed = true;
|
|
336
|
+
detach();
|
|
337
|
+
stopSweep();
|
|
338
|
+
leave();
|
|
339
|
+
unsubscribeTransport?.();
|
|
340
|
+
transport.close?.();
|
|
341
|
+
listeners.clear();
|
|
342
|
+
remote.clear();
|
|
343
|
+
snapshotDirty = true;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
348
|
+
export { viewportSpace as a, moveMessage as c, elementSpace as i, parseMessage as l, throttle as n, PROTOCOL_VERSION as o, customSpace as r, leaveMessage as s, createCursors as t };
|