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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
//#region src/protocol.d.ts
|
|
2
|
+
declare const PROTOCOL_VERSION: 1;
|
|
3
|
+
interface CursorMove {
|
|
4
|
+
v: typeof PROTOCOL_VERSION;
|
|
5
|
+
t: "m";
|
|
6
|
+
id?: string;
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
m?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
interface CursorLeave {
|
|
12
|
+
v: typeof PROTOCOL_VERSION;
|
|
13
|
+
t: "l";
|
|
14
|
+
id?: string;
|
|
15
|
+
}
|
|
16
|
+
type CursorMessage = CursorMove | CursorLeave;
|
|
17
|
+
/**
|
|
18
|
+
* Turns wire data into a message, or null when it is unusable.
|
|
19
|
+
* Coordinates arrive from other clients, so they are clamped rather than trusted.
|
|
20
|
+
*/
|
|
21
|
+
declare function parseMessage(raw: unknown): CursorMessage | null;
|
|
22
|
+
declare function moveMessage(id: string, x: number, y: number, meta?: Record<string, unknown>): CursorMove;
|
|
23
|
+
declare function leaveMessage(id: string): CursorLeave;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/types.d.ts
|
|
26
|
+
interface Point {
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
}
|
|
30
|
+
interface Size {
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
}
|
|
34
|
+
interface RemoteCursor {
|
|
35
|
+
id: string;
|
|
36
|
+
/** normalized 0..1 within the coordinate space */
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
meta?: Record<string, unknown>;
|
|
40
|
+
updatedAt: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Anything that can carry cursor messages between clients.
|
|
44
|
+
*
|
|
45
|
+
* `senderId` is for transports that already know the author of a message
|
|
46
|
+
* (Laravel Echo presence channels, Supabase Realtime). When present it wins
|
|
47
|
+
* over the id inside the payload, so a client cannot claim someone else's id.
|
|
48
|
+
*/
|
|
49
|
+
interface CursorTransport {
|
|
50
|
+
send: (message: CursorMessage) => void;
|
|
51
|
+
onMessage: (handler: (raw: unknown, senderId?: string) => void) => (() => void) | void;
|
|
52
|
+
close?: () => void;
|
|
53
|
+
}
|
|
54
|
+
interface PointerLike {
|
|
55
|
+
clientX: number;
|
|
56
|
+
clientY: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Maps pointer positions to normalized coordinates and back.
|
|
60
|
+
*
|
|
61
|
+
* `viewport` keeps a cursor at the same screen position for everyone regardless of
|
|
62
|
+
* window size and scroll offset; `element` pins it to content that scrolls with the page.
|
|
63
|
+
*/
|
|
64
|
+
interface CoordinateSpace {
|
|
65
|
+
toNormalized: (pointer: PointerLike) => Point | null;
|
|
66
|
+
fromNormalized: (point: Point) => Point;
|
|
67
|
+
size: () => Size;
|
|
68
|
+
target: () => HTMLElement | null;
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/cursors.d.ts
|
|
72
|
+
interface CursorsOptions {
|
|
73
|
+
transport: CursorTransport;
|
|
74
|
+
/** id of the local participant; messages carrying it are ignored on receive */
|
|
75
|
+
selfId: string;
|
|
76
|
+
space?: CoordinateSpace;
|
|
77
|
+
/** how often a move goes out; 40ms is ~25fps and enough for smooth rendering */
|
|
78
|
+
throttleMs?: number;
|
|
79
|
+
/** a cursor with no update within this window disappears */
|
|
80
|
+
ttlMs?: number;
|
|
81
|
+
sweepMs?: number;
|
|
82
|
+
/** upper bound on tracked participants — a flooding client cannot grow the map past it */
|
|
83
|
+
maxRemote?: number;
|
|
84
|
+
enabled?: boolean;
|
|
85
|
+
/** extra payload sent with every move; keep it tiny and free of personal data */
|
|
86
|
+
meta?: () => Record<string, unknown> | undefined;
|
|
87
|
+
onError?: (error: unknown) => void;
|
|
88
|
+
}
|
|
89
|
+
interface CursorsInstance {
|
|
90
|
+
attach: (element?: HTMLElement) => () => void;
|
|
91
|
+
detach: () => void;
|
|
92
|
+
subscribe: (listener: () => void) => () => void;
|
|
93
|
+
getSnapshot: () => readonly RemoteCursor[];
|
|
94
|
+
get: (id: string) => RemoteCursor | undefined;
|
|
95
|
+
/** drop a cursor now, without waiting for its ttl — e.g. when presence reports someone left */
|
|
96
|
+
remove: (id: string) => void;
|
|
97
|
+
/** feed a raw message in by hand — for transports wired outside this instance */
|
|
98
|
+
ingest: (raw: unknown, senderId?: string) => void;
|
|
99
|
+
setEnabled: (enabled: boolean) => void;
|
|
100
|
+
isEnabled: () => boolean;
|
|
101
|
+
leave: () => void;
|
|
102
|
+
readonly space: CoordinateSpace;
|
|
103
|
+
destroy: () => void;
|
|
104
|
+
}
|
|
105
|
+
declare function createCursors(options: CursorsOptions): CursorsInstance;
|
|
106
|
+
//#endregion
|
|
107
|
+
export { CursorTransport as a, RemoteCursor as c, CursorMessage as d, CursorMove as f, parseMessage as g, moveMessage as h, CoordinateSpace as i, Size as l, leaveMessage as m, CursorsOptions as n, Point as o, PROTOCOL_VERSION as p, createCursors as r, PointerLike as s, CursorsInstance as t, CursorLeave as u };
|
package/dist/dom.d.mts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { c as RemoteCursor, t as CursorsInstance } from "./cursors-CyiNtLCv.mjs";
|
|
2
|
+
//#region src/dom/index.d.ts
|
|
3
|
+
interface RenderCursorsOptions {
|
|
4
|
+
cursors: CursorsInstance;
|
|
5
|
+
/** element the overlay is placed in; it needs a non-static position. Defaults to a fixed, viewport-sized overlay */
|
|
6
|
+
container?: HTMLElement;
|
|
7
|
+
color?: (cursor: RemoteCursor) => string;
|
|
8
|
+
label?: (cursor: RemoteCursor) => string | null | undefined;
|
|
9
|
+
zIndex?: number;
|
|
10
|
+
}
|
|
11
|
+
interface CursorsRenderer {
|
|
12
|
+
destroy: () => void;
|
|
13
|
+
}
|
|
14
|
+
declare const CURSOR_CSS = "\n.lc-overlay{position:absolute;top:0;left:0;pointer-events:none;overflow:hidden}\n.lc-overlay[data-fixed=true]{position:fixed;inset:0;width:100%;height:100%}\n.lc-cursor{position:absolute;top:0;left:0;color:var(--lc-color,#888);display:flex;align-items:flex-start;gap:2px;will-change:transform;transition:transform var(--lc-transition,80ms) linear}\n.lc-arrow{flex:none;filter:drop-shadow(0 1px 3px rgba(0,0,0,.3))}\n.lc-label{display:flex;align-items:center;gap:4px;margin-top:12px;padding:2px 7px;border-radius:100px;max-width:var(--lc-label-max-width,160px);overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font:600 11px/1.3 var(--lc-font,system-ui,sans-serif);color:var(--lc-label-color,#fff);background:var(--lc-color,#888);box-shadow:0 2px 8px rgba(0,0,0,.2)}\n@media (prefers-reduced-motion:reduce){.lc-cursor{transition:none}}\n";
|
|
15
|
+
declare function ensureCursorStyles(): void;
|
|
16
|
+
/** Arrow markup; its color comes from the `--lc-color` custom property of the cursor element. */
|
|
17
|
+
declare const ARROW_SVG = "<svg class=\"lc-arrow\" width=\"18\" height=\"18\" viewBox=\"0 0 18 18\" aria-hidden=\"true\"><path d=\"M2 2L14.5 8L9 9.5L7 16L2 2Z\" fill=\"currentColor\" stroke=\"#fff\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/></svg>";
|
|
18
|
+
declare function renderCursors(options: RenderCursorsOptions): CursorsRenderer;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { ARROW_SVG, CURSOR_CSS, CursorsRenderer, RenderCursorsOptions, ensureCursorStyles, renderCursors };
|
package/dist/dom.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { t as cursorColor } from "./color-CN1S_Vq9.mjs";
|
|
2
|
+
//#region src/dom/index.ts
|
|
3
|
+
const STYLE_ID = "live-cursors-style";
|
|
4
|
+
const CURSOR_CSS = `
|
|
5
|
+
.lc-overlay{position:absolute;top:0;left:0;pointer-events:none;overflow:hidden}
|
|
6
|
+
.lc-overlay[data-fixed=true]{position:fixed;inset:0;width:100%;height:100%}
|
|
7
|
+
.lc-cursor{position:absolute;top:0;left:0;color:var(--lc-color,#888);display:flex;align-items:flex-start;gap:2px;will-change:transform;transition:transform var(--lc-transition,80ms) linear}
|
|
8
|
+
.lc-arrow{flex:none;filter:drop-shadow(0 1px 3px rgba(0,0,0,.3))}
|
|
9
|
+
.lc-label{display:flex;align-items:center;gap:4px;margin-top:12px;padding:2px 7px;border-radius:100px;max-width:var(--lc-label-max-width,160px);overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font:600 11px/1.3 var(--lc-font,system-ui,sans-serif);color:var(--lc-label-color,#fff);background:var(--lc-color,#888);box-shadow:0 2px 8px rgba(0,0,0,.2)}
|
|
10
|
+
@media (prefers-reduced-motion:reduce){.lc-cursor{transition:none}}
|
|
11
|
+
`;
|
|
12
|
+
function ensureCursorStyles() {
|
|
13
|
+
if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return;
|
|
14
|
+
const style = document.createElement("style");
|
|
15
|
+
style.id = STYLE_ID;
|
|
16
|
+
style.textContent = CURSOR_CSS;
|
|
17
|
+
document.head.append(style);
|
|
18
|
+
}
|
|
19
|
+
/** Arrow markup; its color comes from the `--lc-color` custom property of the cursor element. */
|
|
20
|
+
const ARROW_SVG = `<svg class="lc-arrow" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M2 2L14.5 8L9 9.5L7 16L2 2Z" fill="currentColor" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
|
|
21
|
+
function renderCursors(options) {
|
|
22
|
+
const { cursors, container, color = (cursor) => cursorColor(cursor.id), label, zIndex = 10 } = options;
|
|
23
|
+
ensureCursorStyles();
|
|
24
|
+
const overlay = document.createElement("div");
|
|
25
|
+
overlay.className = "lc-overlay";
|
|
26
|
+
overlay.setAttribute("aria-hidden", "true");
|
|
27
|
+
overlay.style.zIndex = String(zIndex);
|
|
28
|
+
if (!container) overlay.dataset.fixed = "true";
|
|
29
|
+
(container ?? document.body).append(overlay);
|
|
30
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
31
|
+
let frame = null;
|
|
32
|
+
const syncSize = () => {
|
|
33
|
+
if (!container) return;
|
|
34
|
+
const { width, height } = cursors.space.size();
|
|
35
|
+
overlay.style.width = `${width}px`;
|
|
36
|
+
overlay.style.height = `${height}px`;
|
|
37
|
+
};
|
|
38
|
+
const paint = () => {
|
|
39
|
+
frame = null;
|
|
40
|
+
syncSize();
|
|
41
|
+
const seen = /* @__PURE__ */ new Set();
|
|
42
|
+
for (const cursor of cursors.getSnapshot()) {
|
|
43
|
+
seen.add(cursor.id);
|
|
44
|
+
let node = nodes.get(cursor.id);
|
|
45
|
+
if (!node) {
|
|
46
|
+
node = document.createElement("div");
|
|
47
|
+
node.className = "lc-cursor";
|
|
48
|
+
node.innerHTML = ARROW_SVG;
|
|
49
|
+
nodes.set(cursor.id, node);
|
|
50
|
+
overlay.append(node);
|
|
51
|
+
}
|
|
52
|
+
node.style.setProperty("--lc-color", color(cursor));
|
|
53
|
+
const text = label?.(cursor);
|
|
54
|
+
if (text) {
|
|
55
|
+
let pill = node.querySelector(".lc-label");
|
|
56
|
+
if (!pill) {
|
|
57
|
+
pill = document.createElement("span");
|
|
58
|
+
pill.className = "lc-label";
|
|
59
|
+
node.append(pill);
|
|
60
|
+
}
|
|
61
|
+
if (pill.textContent !== text) pill.textContent = text;
|
|
62
|
+
} else node.querySelector(".lc-label")?.remove();
|
|
63
|
+
const point = cursors.space.fromNormalized(cursor);
|
|
64
|
+
node.style.transform = `translate(${point.x}px, ${point.y}px)`;
|
|
65
|
+
}
|
|
66
|
+
for (const [id, node] of nodes) if (!seen.has(id)) {
|
|
67
|
+
node.remove();
|
|
68
|
+
nodes.delete(id);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const schedule = () => {
|
|
72
|
+
if (frame !== null) return;
|
|
73
|
+
frame = requestAnimationFrame(paint);
|
|
74
|
+
};
|
|
75
|
+
const unsubscribe = cursors.subscribe(schedule);
|
|
76
|
+
const resizeObserver = container && typeof ResizeObserver !== "undefined" ? new ResizeObserver(schedule) : null;
|
|
77
|
+
const mutationObserver = container && typeof MutationObserver !== "undefined" ? new MutationObserver(schedule) : null;
|
|
78
|
+
resizeObserver?.observe(container);
|
|
79
|
+
mutationObserver?.observe(container, {
|
|
80
|
+
childList: true,
|
|
81
|
+
subtree: true,
|
|
82
|
+
characterData: true
|
|
83
|
+
});
|
|
84
|
+
window.addEventListener("resize", schedule, { passive: true });
|
|
85
|
+
schedule();
|
|
86
|
+
return { destroy() {
|
|
87
|
+
unsubscribe();
|
|
88
|
+
if (frame !== null) cancelAnimationFrame(frame);
|
|
89
|
+
resizeObserver?.disconnect();
|
|
90
|
+
mutationObserver?.disconnect();
|
|
91
|
+
window.removeEventListener("resize", schedule);
|
|
92
|
+
overlay.remove();
|
|
93
|
+
nodes.clear();
|
|
94
|
+
} };
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
export { ARROW_SVG, CURSOR_CSS, ensureCursorStyles, renderCursors };
|
package/dist/echo.d.mts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { a as CursorTransport } from "./cursors-CyiNtLCv.mjs";
|
|
2
|
+
//#region src/transports/echo.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The slice of a Laravel Echo private/presence channel this transport needs.
|
|
5
|
+
* Typed structurally so the package never has to depend on laravel-echo itself.
|
|
6
|
+
*/
|
|
7
|
+
interface EchoWhisperChannel {
|
|
8
|
+
whisper: (event: string, data: unknown) => unknown;
|
|
9
|
+
listenForWhisper: (event: string, callback: (payload: unknown) => void) => unknown;
|
|
10
|
+
stopListeningForWhisper?: (event: string) => unknown;
|
|
11
|
+
}
|
|
12
|
+
interface EchoTransportOptions {
|
|
13
|
+
channel: EchoWhisperChannel | (() => EchoWhisperChannel);
|
|
14
|
+
/** whisper event name; must match on every client */
|
|
15
|
+
event?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Laravel Echo transport over client events (whisper). Requires a private or presence
|
|
19
|
+
* channel — Reverb and Pusher reject client events on public channels.
|
|
20
|
+
*
|
|
21
|
+
* Echo's `stopListeningForWhisper(event)` drops every listener of an event on the channel,
|
|
22
|
+
* so give this transport an `event` name of its own if the app whispers the same event elsewhere.
|
|
23
|
+
*/
|
|
24
|
+
declare function echoTransport(options: EchoTransportOptions): CursorTransport;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { EchoTransportOptions, EchoWhisperChannel, echoTransport };
|
package/dist/echo.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/transports/echo.ts
|
|
2
|
+
/**
|
|
3
|
+
* Laravel Echo transport over client events (whisper). Requires a private or presence
|
|
4
|
+
* channel — Reverb and Pusher reject client events on public channels.
|
|
5
|
+
*
|
|
6
|
+
* Echo's `stopListeningForWhisper(event)` drops every listener of an event on the channel,
|
|
7
|
+
* so give this transport an `event` name of its own if the app whispers the same event elsewhere.
|
|
8
|
+
*/
|
|
9
|
+
function echoTransport(options) {
|
|
10
|
+
const { channel, event = "cursor" } = options;
|
|
11
|
+
const resolve = () => typeof channel === "function" ? channel() : channel;
|
|
12
|
+
return {
|
|
13
|
+
send(message) {
|
|
14
|
+
resolve().whisper(event, message);
|
|
15
|
+
},
|
|
16
|
+
onMessage(handler) {
|
|
17
|
+
const target = resolve();
|
|
18
|
+
target.listenForWhisper(event, (payload) => handler(payload));
|
|
19
|
+
return () => target.stopListeningForWhisper?.(event);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { echoTransport };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { i as CoordinateSpace } from "./cursors-CyiNtLCv.mjs";
|
|
2
|
+
//#region src/color.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic hue per id: the same participant keeps the same color across
|
|
5
|
+
* reloads and across clients without any color negotiation.
|
|
6
|
+
*/
|
|
7
|
+
declare function hashHue(id: string): number;
|
|
8
|
+
interface ColorOptions {
|
|
9
|
+
saturation?: number;
|
|
10
|
+
lightness?: number;
|
|
11
|
+
}
|
|
12
|
+
declare function cursorColor(id: string, options?: ColorOptions): string;
|
|
13
|
+
/**
|
|
14
|
+
* Picks from a fixed palette instead of the full hue circle — for brand palettes
|
|
15
|
+
* or when hues have to stay distinguishable for color-blind viewers.
|
|
16
|
+
*/
|
|
17
|
+
declare function paletteColor(id: string, palette: readonly string[]): string;
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/space.d.ts
|
|
20
|
+
declare function viewportSpace(): CoordinateSpace;
|
|
21
|
+
/**
|
|
22
|
+
* Scroll-aware space: coordinates are relative to the scrollable content of an element,
|
|
23
|
+
* so a cursor stays on the content it points at even when participants scroll differently.
|
|
24
|
+
*/
|
|
25
|
+
declare function elementSpace(resolve: HTMLElement | (() => HTMLElement | null)): CoordinateSpace;
|
|
26
|
+
declare function customSpace(space: CoordinateSpace): CoordinateSpace;
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/throttle.d.ts
|
|
29
|
+
interface Throttled<A extends unknown[]> {
|
|
30
|
+
(...args: A): void;
|
|
31
|
+
flush: () => void;
|
|
32
|
+
cancel: () => void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Leading + trailing throttle: the first move goes out immediately and the last
|
|
36
|
+
* one is never dropped, which is what keeps a cursor from freezing mid-screen.
|
|
37
|
+
*/
|
|
38
|
+
declare function throttle<A extends unknown[]>(fn: (...args: A) => void, ms: number): Throttled<A>;
|
|
39
|
+
//#endregion
|
|
40
|
+
export { viewportSpace as a, hashHue as c, elementSpace as i, paletteColor as l, throttle as n, ColorOptions as o, customSpace as r, cursorColor as s, Throttled as t };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { a as CursorTransport, c as RemoteCursor, d as CursorMessage, f as CursorMove, g as parseMessage, h as moveMessage, i as CoordinateSpace, l as Size, m as leaveMessage, n as CursorsOptions, o as Point, p as PROTOCOL_VERSION, r as createCursors, s as PointerLike, t as CursorsInstance, u as CursorLeave } from "./cursors-CyiNtLCv.mjs";
|
|
2
|
+
import { a as viewportSpace, c as hashHue, i as elementSpace, l as paletteColor, n as throttle, o as ColorOptions, r as customSpace, s as cursorColor, t as Throttled } from "./index-4O7Ji1vk.mjs";
|
|
3
|
+
export { type ColorOptions, type CoordinateSpace, type CursorLeave, type CursorMessage, type CursorMove, type CursorTransport, type CursorsInstance, type CursorsOptions, PROTOCOL_VERSION, type Point, type PointerLike, type RemoteCursor, type Size, type Throttled, createCursors, cursorColor, customSpace, elementSpace, hashHue, leaveMessage, moveMessage, paletteColor, parseMessage, throttle, viewportSpace };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { n as hashHue, r as paletteColor, t as cursorColor } from "./color-CN1S_Vq9.mjs";
|
|
2
|
+
import { a as viewportSpace, c as moveMessage, i as elementSpace, l as parseMessage, n as throttle, o as PROTOCOL_VERSION, r as customSpace, s as leaveMessage, t as createCursors } from "./cursors-BhKZnRWg.mjs";
|
|
3
|
+
export { PROTOCOL_VERSION, createCursors, cursorColor, customSpace, elementSpace, hashHue, leaveMessage, moveMessage, paletteColor, parseMessage, throttle, viewportSpace };
|
package/dist/react.d.mts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { a as CursorTransport, c as RemoteCursor, n as CursorsOptions, t as CursorsInstance } from "./cursors-CyiNtLCv.mjs";
|
|
2
|
+
import { ReactElement, ReactNode } from "react";
|
|
3
|
+
//#region src/react/index.d.ts
|
|
4
|
+
interface UseCursorsOptions extends Omit<CursorsOptions, "transport" | "space"> {
|
|
5
|
+
/** a factory keeps BroadcastChannel and WebSocket out of the server render */
|
|
6
|
+
transport: CursorTransport | (() => CursorTransport);
|
|
7
|
+
space?: CursorsOptions["space"];
|
|
8
|
+
container?: HTMLElement | null;
|
|
9
|
+
}
|
|
10
|
+
interface UseCursorsReturn {
|
|
11
|
+
cursors: CursorsInstance | null;
|
|
12
|
+
list: readonly RemoteCursor[];
|
|
13
|
+
}
|
|
14
|
+
declare function useCursors(options: UseCursorsOptions): UseCursorsReturn;
|
|
15
|
+
interface LiveCursorsProps {
|
|
16
|
+
cursors: CursorsInstance | null;
|
|
17
|
+
color?: (cursor: RemoteCursor) => string;
|
|
18
|
+
label?: (cursor: RemoteCursor) => string | null | undefined;
|
|
19
|
+
/** custom label content, keeping the built-in arrow; `children` replaces the whole cursor */
|
|
20
|
+
renderLabel?: (cursor: RemoteCursor, color: string) => ReactNode;
|
|
21
|
+
zIndex?: number;
|
|
22
|
+
children?: (cursor: RemoteCursor, color: string) => ReactNode;
|
|
23
|
+
}
|
|
24
|
+
declare function LiveCursors(props: LiveCursorsProps): ReactElement | null;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { LiveCursors, LiveCursorsProps, UseCursorsOptions, UseCursorsReturn, useCursors };
|
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { t as cursorColor } from "./color-CN1S_Vq9.mjs";
|
|
2
|
+
import { i as elementSpace, t as createCursors } from "./cursors-BhKZnRWg.mjs";
|
|
3
|
+
import { ARROW_SVG, ensureCursorStyles } from "./dom.mjs";
|
|
4
|
+
import { createElement, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
5
|
+
//#region src/react/index.ts
|
|
6
|
+
const EMPTY = Object.freeze([]);
|
|
7
|
+
function useCursors(options) {
|
|
8
|
+
const { transport, container, space, ...rest } = options;
|
|
9
|
+
const [instance, setInstance] = useState(null);
|
|
10
|
+
const settingsRef = useRef(rest);
|
|
11
|
+
settingsRef.current = rest;
|
|
12
|
+
const containerRef = useRef(container);
|
|
13
|
+
containerRef.current = container;
|
|
14
|
+
const setupRef = useRef({
|
|
15
|
+
transport,
|
|
16
|
+
space
|
|
17
|
+
});
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const setup = setupRef.current;
|
|
20
|
+
const created = createCursors({
|
|
21
|
+
...settingsRef.current,
|
|
22
|
+
transport: typeof setup.transport === "function" ? setup.transport() : setup.transport,
|
|
23
|
+
space: setup.space ?? (containerRef.current !== void 0 ? elementSpace(() => containerRef.current ?? null) : void 0)
|
|
24
|
+
});
|
|
25
|
+
setInstance(created);
|
|
26
|
+
return () => {
|
|
27
|
+
created.destroy();
|
|
28
|
+
setInstance(null);
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!instance) return;
|
|
33
|
+
if (container === void 0) return instance.attach();
|
|
34
|
+
if (container === null) return;
|
|
35
|
+
return instance.attach(container);
|
|
36
|
+
}, [instance, container]);
|
|
37
|
+
return {
|
|
38
|
+
cursors: instance,
|
|
39
|
+
list: useSyncExternalStore((onChange) => instance?.subscribe(onChange) ?? (() => {}), () => instance?.getSnapshot() ?? EMPTY, () => EMPTY)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function LiveCursors(props) {
|
|
43
|
+
const { cursors, color, label, renderLabel, zIndex = 10, children } = props;
|
|
44
|
+
const [resizeTick, setResizeTick] = useState(0);
|
|
45
|
+
ensureCursorStyles();
|
|
46
|
+
const list = useSyncExternalStore((onChange) => cursors?.subscribe(onChange) ?? (() => {}), () => cursors?.getSnapshot() ?? EMPTY, () => EMPTY);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const onResize = () => setResizeTick((value) => value + 1);
|
|
49
|
+
window.addEventListener("resize", onResize, { passive: true });
|
|
50
|
+
return () => window.removeEventListener("resize", onResize);
|
|
51
|
+
}, []);
|
|
52
|
+
const isFixed = cursors?.space.target() === null;
|
|
53
|
+
const size = cursors?.space.size() ?? {
|
|
54
|
+
width: 0,
|
|
55
|
+
height: 0
|
|
56
|
+
};
|
|
57
|
+
if (!cursors) return null;
|
|
58
|
+
return createElement("div", {
|
|
59
|
+
"className": "lc-overlay",
|
|
60
|
+
"aria-hidden": true,
|
|
61
|
+
"data-fixed": isFixed ? "true" : void 0,
|
|
62
|
+
"style": isFixed ? { zIndex } : {
|
|
63
|
+
zIndex,
|
|
64
|
+
width: size.width,
|
|
65
|
+
height: size.height
|
|
66
|
+
}
|
|
67
|
+
}, list.map((cursor) => {
|
|
68
|
+
const tint = color?.(cursor) ?? cursorColor(cursor.id);
|
|
69
|
+
const point = cursors.space.fromNormalized(cursor);
|
|
70
|
+
const text = label?.(cursor);
|
|
71
|
+
return createElement("div", {
|
|
72
|
+
key: cursor.id,
|
|
73
|
+
className: "lc-cursor",
|
|
74
|
+
style: {
|
|
75
|
+
"transform": `translate(${point.x}px, ${point.y}px)`,
|
|
76
|
+
"--lc-color": tint
|
|
77
|
+
}
|
|
78
|
+
}, children ? children(cursor, tint) : [createElement("span", {
|
|
79
|
+
key: "arrow",
|
|
80
|
+
dangerouslySetInnerHTML: { __html: ARROW_SVG }
|
|
81
|
+
}), renderLabel ? createElement("span", {
|
|
82
|
+
key: "label",
|
|
83
|
+
className: "lc-label"
|
|
84
|
+
}, renderLabel(cursor, tint)) : text ? createElement("span", {
|
|
85
|
+
key: "label",
|
|
86
|
+
className: "lc-label"
|
|
87
|
+
}, text) : null]);
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
export { LiveCursors, useCursors };
|
package/dist/vue.d.mts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { a as CursorTransport, c as RemoteCursor, n as CursorsOptions, t as CursorsInstance } from "./cursors-CyiNtLCv.mjs";
|
|
2
|
+
import { PropType, ShallowRef } from "vue";
|
|
3
|
+
//#region src/vue/index.d.ts
|
|
4
|
+
type MaybeGetter<T> = T | (() => T);
|
|
5
|
+
interface UseCursorsOptions extends Omit<CursorsOptions, "transport" | "space"> {
|
|
6
|
+
/** a factory keeps BroadcastChannel and WebSocket out of the server render */
|
|
7
|
+
transport: MaybeGetter<CursorTransport>;
|
|
8
|
+
space?: CursorsOptions["space"];
|
|
9
|
+
/** shorthand for `space: elementSpace(...)`; also the element pointer events bind to */
|
|
10
|
+
container?: MaybeGetter<HTMLElement | null | undefined>;
|
|
11
|
+
}
|
|
12
|
+
interface UseCursorsReturn {
|
|
13
|
+
cursors: ShallowRef<CursorsInstance | null>;
|
|
14
|
+
list: ShallowRef<readonly RemoteCursor[]>;
|
|
15
|
+
}
|
|
16
|
+
declare function useCursors(options: UseCursorsOptions): UseCursorsReturn;
|
|
17
|
+
declare const LiveCursors: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
18
|
+
cursors: {
|
|
19
|
+
type: PropType<CursorsInstance | null>;
|
|
20
|
+
default: null;
|
|
21
|
+
};
|
|
22
|
+
color: {
|
|
23
|
+
type: PropType<(cursor: RemoteCursor) => string>;
|
|
24
|
+
default: undefined;
|
|
25
|
+
};
|
|
26
|
+
label: {
|
|
27
|
+
type: PropType<(cursor: RemoteCursor) => string | null | undefined>;
|
|
28
|
+
default: undefined;
|
|
29
|
+
};
|
|
30
|
+
zIndex: {
|
|
31
|
+
type: NumberConstructor;
|
|
32
|
+
default: number;
|
|
33
|
+
};
|
|
34
|
+
}>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
35
|
+
[key: string]: any;
|
|
36
|
+
}> | null, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
37
|
+
cursors: {
|
|
38
|
+
type: PropType<CursorsInstance | null>;
|
|
39
|
+
default: null;
|
|
40
|
+
};
|
|
41
|
+
color: {
|
|
42
|
+
type: PropType<(cursor: RemoteCursor) => string>;
|
|
43
|
+
default: undefined;
|
|
44
|
+
};
|
|
45
|
+
label: {
|
|
46
|
+
type: PropType<(cursor: RemoteCursor) => string | null | undefined>;
|
|
47
|
+
default: undefined;
|
|
48
|
+
};
|
|
49
|
+
zIndex: {
|
|
50
|
+
type: NumberConstructor;
|
|
51
|
+
default: number;
|
|
52
|
+
};
|
|
53
|
+
}>> & Readonly<{}>, {
|
|
54
|
+
cursors: CursorsInstance | null;
|
|
55
|
+
color: (cursor: RemoteCursor) => string;
|
|
56
|
+
label: (cursor: RemoteCursor) => string | null | undefined;
|
|
57
|
+
zIndex: number;
|
|
58
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
59
|
+
//#endregion
|
|
60
|
+
export { LiveCursors, UseCursorsOptions, UseCursorsReturn, useCursors };
|
package/dist/vue.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { t as cursorColor } from "./color-CN1S_Vq9.mjs";
|
|
2
|
+
import { i as elementSpace, t as createCursors } from "./cursors-BhKZnRWg.mjs";
|
|
3
|
+
import { ARROW_SVG, ensureCursorStyles } from "./dom.mjs";
|
|
4
|
+
import { computed, defineComponent, h, onBeforeUnmount, onMounted, shallowRef, toValue, watch } from "vue";
|
|
5
|
+
//#region src/vue/index.ts
|
|
6
|
+
function useCursors(options) {
|
|
7
|
+
const cursors = shallowRef(null);
|
|
8
|
+
const list = shallowRef([]);
|
|
9
|
+
let unsubscribe = null;
|
|
10
|
+
onMounted(() => {
|
|
11
|
+
const { transport, container, space, ...rest } = options;
|
|
12
|
+
const resolveContainer = () => toValue(container) ?? null;
|
|
13
|
+
const instance = createCursors({
|
|
14
|
+
...rest,
|
|
15
|
+
transport: typeof transport === "function" ? transport() : transport,
|
|
16
|
+
space: space ?? (container ? elementSpace(resolveContainer) : void 0)
|
|
17
|
+
});
|
|
18
|
+
cursors.value = instance;
|
|
19
|
+
unsubscribe = instance.subscribe(() => {
|
|
20
|
+
list.value = instance.getSnapshot();
|
|
21
|
+
});
|
|
22
|
+
if (container) watch(() => resolveContainer(), (el) => {
|
|
23
|
+
if (el) instance.attach(el);
|
|
24
|
+
}, {
|
|
25
|
+
immediate: true,
|
|
26
|
+
flush: "post"
|
|
27
|
+
});
|
|
28
|
+
else instance.attach();
|
|
29
|
+
});
|
|
30
|
+
onBeforeUnmount(() => {
|
|
31
|
+
unsubscribe?.();
|
|
32
|
+
cursors.value?.destroy();
|
|
33
|
+
cursors.value = null;
|
|
34
|
+
list.value = [];
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
cursors,
|
|
38
|
+
list
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const LiveCursors = defineComponent({
|
|
42
|
+
name: "LiveCursors",
|
|
43
|
+
props: {
|
|
44
|
+
cursors: {
|
|
45
|
+
type: Object,
|
|
46
|
+
default: null
|
|
47
|
+
},
|
|
48
|
+
color: {
|
|
49
|
+
type: Function,
|
|
50
|
+
default: void 0
|
|
51
|
+
},
|
|
52
|
+
label: {
|
|
53
|
+
type: Function,
|
|
54
|
+
default: void 0
|
|
55
|
+
},
|
|
56
|
+
zIndex: {
|
|
57
|
+
type: Number,
|
|
58
|
+
default: 10
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
setup(props, { slots }) {
|
|
62
|
+
const list = shallowRef([]);
|
|
63
|
+
const version = shallowRef(0);
|
|
64
|
+
let unsubscribe = null;
|
|
65
|
+
ensureCursorStyles();
|
|
66
|
+
watch(() => props.cursors, (instance) => {
|
|
67
|
+
unsubscribe?.();
|
|
68
|
+
unsubscribe = null;
|
|
69
|
+
list.value = [];
|
|
70
|
+
if (!instance) return;
|
|
71
|
+
unsubscribe = instance.subscribe(() => {
|
|
72
|
+
list.value = instance.getSnapshot();
|
|
73
|
+
version.value++;
|
|
74
|
+
});
|
|
75
|
+
}, { immediate: true });
|
|
76
|
+
onMounted(() => {
|
|
77
|
+
const bump = () => {
|
|
78
|
+
version.value++;
|
|
79
|
+
};
|
|
80
|
+
window.addEventListener("resize", bump, { passive: true });
|
|
81
|
+
const target = props.cursors?.space.target() ?? null;
|
|
82
|
+
const resizeObserver = target && typeof ResizeObserver !== "undefined" ? new ResizeObserver(bump) : null;
|
|
83
|
+
const mutationObserver = target && typeof MutationObserver !== "undefined" ? new MutationObserver(bump) : null;
|
|
84
|
+
if (target) {
|
|
85
|
+
resizeObserver?.observe(target);
|
|
86
|
+
mutationObserver?.observe(target, {
|
|
87
|
+
childList: true,
|
|
88
|
+
subtree: true,
|
|
89
|
+
characterData: true
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
onBeforeUnmount(() => {
|
|
93
|
+
window.removeEventListener("resize", bump);
|
|
94
|
+
resizeObserver?.disconnect();
|
|
95
|
+
mutationObserver?.disconnect();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
onBeforeUnmount(() => unsubscribe?.());
|
|
99
|
+
const tint = (cursor) => props.color?.(cursor) ?? cursorColor(cursor.id);
|
|
100
|
+
const overlaySize = computed(() => {
|
|
101
|
+
version.value;
|
|
102
|
+
return props.cursors?.space.size() ?? {
|
|
103
|
+
width: 0,
|
|
104
|
+
height: 0
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
const isFixed = computed(() => props.cursors?.space.target() === null);
|
|
108
|
+
return () => {
|
|
109
|
+
const instance = props.cursors;
|
|
110
|
+
if (!instance) return null;
|
|
111
|
+
return h("div", {
|
|
112
|
+
"class": "lc-overlay",
|
|
113
|
+
"aria-hidden": "true",
|
|
114
|
+
"data-fixed": isFixed.value ? "true" : void 0,
|
|
115
|
+
"style": isFixed.value ? { zIndex: props.zIndex } : {
|
|
116
|
+
zIndex: props.zIndex,
|
|
117
|
+
width: `${overlaySize.value.width}px`,
|
|
118
|
+
height: `${overlaySize.value.height}px`
|
|
119
|
+
}
|
|
120
|
+
}, list.value.map((cursor) => {
|
|
121
|
+
version.value;
|
|
122
|
+
const point = instance.space.fromNormalized(cursor);
|
|
123
|
+
const color = tint(cursor);
|
|
124
|
+
const text = props.label?.(cursor);
|
|
125
|
+
return h("div", {
|
|
126
|
+
key: cursor.id,
|
|
127
|
+
class: "lc-cursor",
|
|
128
|
+
style: {
|
|
129
|
+
"transform": `translate(${point.x}px, ${point.y}px)`,
|
|
130
|
+
"--lc-color": color
|
|
131
|
+
}
|
|
132
|
+
}, slots.default ? slots.default({
|
|
133
|
+
cursor,
|
|
134
|
+
color
|
|
135
|
+
}) : [h("span", { innerHTML: ARROW_SVG }), slots.label ? h("span", { class: "lc-label" }, slots.label({
|
|
136
|
+
cursor,
|
|
137
|
+
color
|
|
138
|
+
})) : text ? h("span", { class: "lc-label" }, text) : null]);
|
|
139
|
+
}));
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
//#endregion
|
|
144
|
+
export { LiveCursors, useCursors };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { a as CursorTransport } from "./cursors-CyiNtLCv.mjs";
|
|
2
|
+
//#region src/transports/websocket.d.ts
|
|
3
|
+
interface WebSocketTransportOptions {
|
|
4
|
+
url: string | (() => string);
|
|
5
|
+
/** sent as `{ room, ...message }` so a single endpoint can host many rooms */
|
|
6
|
+
room?: string;
|
|
7
|
+
protocols?: string | string[];
|
|
8
|
+
reconnect?: boolean;
|
|
9
|
+
/** first retry delay; doubles up to maxDelayMs */
|
|
10
|
+
reconnectDelayMs?: number;
|
|
11
|
+
maxDelayMs?: number;
|
|
12
|
+
onError?: (error: unknown) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Raw WebSocket transport. Cursor updates are disposable, so nothing is buffered
|
|
16
|
+
* while the socket is down — the next pointer move takes over.
|
|
17
|
+
*/
|
|
18
|
+
declare function websocketTransport(options: WebSocketTransportOptions): CursorTransport;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { WebSocketTransportOptions, websocketTransport };
|