diff --git a/README.md b/README.md index d7485a1..26a63a3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ A collaborative infinite whiteboard for teaching — pen, shapes, text, PDFs, re ## Status -Milestone 1 — Skeleton. Vite + React app, Supabase magic-link auth, two routes (`/`, `/board/:id`), workspace stubs for `@notux/canvas`, `@notux/sync`, `@notux/ui`, `@notux/types`, Supabase migration, and GitHub Pages deploy workflow. Canvas, real-time sync, tools, PDF import, and Liquid Glass dock land in later milestones. +Milestone 4 — Realtime collaboration. The canvas (M2), local Yjs persistence + undo/redo (M3), and now realtime multiplayer sync are in place: edits and live cursors sync between everyone on a board over Supabase Realtime, with the board falling back to local-only mode when Supabase isn't configured. PDF import and the Liquid Glass dock land in later milestones. + +Realtime requires Supabase Realtime to be reachable by the `anon` role for the `notux-board-*` broadcast topics (the default, no-authorization Realtime mode works out of the box). Late-joiners get current board state from connected peers; persisting snapshots server-side for offline late-joiners is a later milestone. ## Repo layout diff --git a/apps/web/src/features/canvas/useIdentity.ts b/apps/web/src/features/canvas/useIdentity.ts new file mode 100644 index 0000000..ad0a759 --- /dev/null +++ b/apps/web/src/features/canvas/useIdentity.ts @@ -0,0 +1,33 @@ +import { useMemo } from "react"; +import { colorForSeed } from "@notux/sync"; +import { useMagicLink } from "../auth/useMagicLink"; + +export interface Identity { + name: string; + color: string; +} + +// A stable per-tab id for anonymous guests, so a signed-out user keeps one +// consistent name color for the session. +function guestSeed(): string { + const KEY = "notux-guest-id"; + let id = sessionStorage.getItem(KEY); + if (!id) { + id = Math.random().toString(36).slice(2); + sessionStorage.setItem(KEY, id); + } + return id; +} + +// Presence identity: the signed-in user's email local-part, or "Guest". The +// color is deterministic from a stable seed so the same person is the same +// color across all clients. +export function useIdentity(): Identity { + const { session } = useMagicLink(); + return useMemo(() => { + const email = session?.user?.email ?? null; + const name = email ? (email.split("@")[0] ?? email) : "Guest"; + const seed = session?.user?.id ?? guestSeed(); + return { name, color: colorForSeed(seed) }; + }, [session]); +} diff --git a/apps/web/src/routes/Board.tsx b/apps/web/src/routes/Board.tsx index 1b55483..0ce6389 100644 --- a/apps/web/src/routes/Board.tsx +++ b/apps/web/src/routes/Board.tsx @@ -4,19 +4,28 @@ import { CanvasStage, DEFAULT_PAGE_ID, useShapeStore } from "@notux/canvas"; import { SaveStatus } from "../features/canvas/SaveStatus"; import { SelectionInspector } from "../features/canvas/SelectionInspector"; import { ToolPalette } from "../features/canvas/ToolPalette"; +import { useIdentity } from "../features/canvas/useIdentity"; +import { getSupabase } from "../lib/supabase"; export default function Board() { const { boardId } = useParams<{ boardId: string }>(); const [ready, setReady] = useState(false); + const identity = useIdentity(); useEffect(() => { if (!boardId) return; setReady(false); + // Enable realtime collaboration when Supabase is configured; otherwise the + // board runs in local-only mode against IndexedDB. + const client = getSupabase(); + useShapeStore + .getState() + .configureRealtime(client ? { client, identity } : null); useShapeStore .getState() .initBoard(boardId) .then(() => setReady(true)); - }, [boardId]); + }, [boardId, identity]); if (!ready) { return ( diff --git a/packages/canvas/package.json b/packages/canvas/package.json index 26db31c..cb569db 100644 --- a/packages/canvas/package.json +++ b/packages/canvas/package.json @@ -14,6 +14,7 @@ "dependencies": { "@notux/sync": "workspace:*", "@notux/types": "workspace:*", + "@supabase/supabase-js": "^2.106.2", "konva": "^9.3.16", "nanoid": "^5.0.7", "perfect-freehand": "^1.2.2", diff --git a/packages/canvas/src/CanvasStage.tsx b/packages/canvas/src/CanvasStage.tsx index ae46e73..3a029b5 100644 --- a/packages/canvas/src/CanvasStage.tsx +++ b/packages/canvas/src/CanvasStage.tsx @@ -3,9 +3,11 @@ import type { ToolKind, YShape } from "@notux/types"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Stage } from "react-konva"; import { newAuthorId } from "./ids"; +import { useAwareness } from "./hooks/useAwareness"; import { useUndoManager } from "./hooks/useUndoManager"; import { BackgroundLayer } from "./layers/BackgroundLayer"; import { OverlayLayer } from "./layers/OverlayLayer"; +import { PresenceLayer } from "./layers/PresenceLayer"; import { ShapesLayer } from "./layers/ShapesLayer"; import { TransformLayer } from "./layers/TransformLayer"; import { useDraftStore } from "./store/draftStore"; @@ -57,6 +59,24 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro const [spaceHeld, setSpaceHeld] = useState(false); const { undo, redo } = useUndoManager(pageId); + const awareness = useAwareness(); + + // Publish the local cursor (world coords) to awareness, throttled to one + // update per animation frame so rapid pointer moves don't flood the channel. + const cursorRafRef = useRef(null); + const pendingCursorRef = useRef<{ x: number; y: number } | null>(null); + const publishCursor = useCallback( + (world: { x: number; y: number }) => { + if (!awareness) return; + pendingCursorRef.current = world; + if (cursorRafRef.current !== null) return; + cursorRafRef.current = requestAnimationFrame(() => { + cursorRafRef.current = null; + awareness.setLocalStateField("cursor", pendingCursorRef.current); + }); + }, + [awareness], + ); const tool = useToolStore((s) => s.tool); const selection = useToolStore((s) => s.selection); @@ -209,6 +229,19 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro }; }, [buildToolContext, undo, redo, pageId]); + // Publish the local selection to awareness so peers can highlight it. + useEffect(() => { + awareness?.setLocalStateField("selection", Array.from(selection)); + }, [awareness, selection]); + + // Clear our presence when the canvas unmounts (navigate away from the board). + useEffect(() => { + return () => { + if (cursorRafRef.current !== null) cancelAnimationFrame(cursorRafRef.current); + awareness?.setLocalStateField("cursor", null); + }; + }, [awareness]); + const panRef = useRef<{ active: boolean; lastX: number; lastY: number }>({ active: false, lastX: 0, @@ -268,11 +301,22 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro setViewport((v) => ({ ...v, x: v.x + dx, y: v.y + dy })); return; } - toolRef.current.onPointerMove(pointerToToolPoint(native), buildToolContext()); + const point = pointerToToolPoint(native); + publishCursor({ x: point.x, y: point.y }); + toolRef.current.onPointerMove(point, buildToolContext()); }, - [viewport, buildToolContext], + [viewport, buildToolContext, publishCursor], ); + // Hide our cursor for peers when the pointer leaves the canvas. + const onPointerLeave = useCallback(() => { + if (cursorRafRef.current !== null) { + cancelAnimationFrame(cursorRafRef.current); + cursorRafRef.current = null; + } + awareness?.setLocalStateField("cursor", null); + }, [awareness]); + const onPointerUp = useCallback( (evt: React.PointerEvent) => { const native = evt.nativeEvent; @@ -377,6 +421,7 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} + onPointerLeave={onPointerLeave} onWheel={onWheel} onDoubleClick={onDoubleClick} onContextMenu={(e) => e.preventDefault()} @@ -393,15 +438,8 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro > - {tool === "select" && ( - - )} - + + s._awareness); +} diff --git a/packages/canvas/src/hooks/useRemoteCursors.ts b/packages/canvas/src/hooks/useRemoteCursors.ts new file mode 100644 index 0000000..c1b1c25 --- /dev/null +++ b/packages/canvas/src/hooks/useRemoteCursors.ts @@ -0,0 +1,54 @@ +import { useMemo, useSyncExternalStore } from "react"; +import type { Awareness } from "@notux/sync"; + +export interface RemoteCursor { + clientID: number; + name: string; + color: string; + cursor: { x: number; y: number } | null; + selection: string[]; +} + +const EMPTY: RemoteCursor[] = []; + +// Subscribes to awareness and returns every peer's presence (excluding self). +// Uses useSyncExternalStore so React re-renders on awareness "change" events. +export function useRemoteCursors(awareness: Awareness | null): RemoteCursor[] { + // A per-awareness external store. getSnapshot must return a stable reference + // between change events, so we keep the computed array in a closure and only + // recompute when awareness actually fires "change". + const store = useMemo(() => { + const compute = (): RemoteCursor[] => { + if (!awareness) return EMPTY; + const out: RemoteCursor[] = []; + awareness.getStates().forEach((state, clientID) => { + if (clientID === awareness.clientID) return; + const user = (state.user ?? {}) as { name?: string; color?: string }; + out.push({ + clientID, + name: user.name ?? "Guest", + color: user.color ?? "#8e8e93", + cursor: (state.cursor as { x: number; y: number } | null) ?? null, + selection: (state.selection as string[]) ?? [], + }); + }); + return out; + }; + + let snapshot = compute(); + return { + subscribe(onChange: () => void) { + if (!awareness) return () => {}; + const handler = () => { + snapshot = compute(); + onChange(); + }; + awareness.on("change", handler); + return () => awareness.off("change", handler); + }, + getSnapshot: () => snapshot, + }; + }, [awareness]); + + return useSyncExternalStore(store.subscribe, store.getSnapshot, () => EMPTY); +} diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts index b65415e..795ed89 100644 --- a/packages/canvas/src/index.ts +++ b/packages/canvas/src/index.ts @@ -1,6 +1,10 @@ export { CanvasStage } from "./CanvasStage"; +export { useAwareness } from "./hooks/useAwareness"; +export { useRemoteCursors } from "./hooks/useRemoteCursors"; +export type { RemoteCursor } from "./hooks/useRemoteCursors"; export { useUndoManager } from "./hooks/useUndoManager"; export { useShapeStore } from "./store/shapeStore"; +export type { RealtimeConfig } from "./store/shapeStore"; export { useToolStore } from "./store/toolStore"; export type { ToolOptions } from "./store/toolStore"; export { DEFAULT_PAGE_ID } from "./store/pageStore"; diff --git a/packages/canvas/src/layers/PresenceLayer.tsx b/packages/canvas/src/layers/PresenceLayer.tsx new file mode 100644 index 0000000..d9efa97 --- /dev/null +++ b/packages/canvas/src/layers/PresenceLayer.tsx @@ -0,0 +1,51 @@ +import type { Awareness } from "@notux/sync"; +import { Group, Label, Layer, Line, Tag, Text } from "react-konva"; +import { useRemoteCursors } from "../hooks/useRemoteCursors"; +import type { ViewportState } from "../viewport/Viewport"; + +interface Props { + awareness: Awareness | null; + viewport: ViewportState; +} + +// A classic pointer-arrow glyph, drawn in cursor-local coordinates. +const ARROW_POINTS = [0, 0, 0, 16, 4, 12, 7, 18, 10, 17, 7, 11, 12, 11]; + +// Renders remote collaborators' cursors and name tags. Non-interactive overlay +// mounted above all content. Cursor positions are world coords (the Stage is +// already viewport-transformed), and the glyph is counter-scaled by 1/scale so +// it stays a constant size on screen regardless of zoom. +export function PresenceLayer({ awareness, viewport }: Props) { + const cursors = useRemoteCursors(awareness); + const inv = 1 / viewport.scale; + + return ( + + {cursors.map((c) => { + if (!c.cursor) return null; + return ( + + + + + ); + })} + + ); +} diff --git a/packages/canvas/src/store/shapeStore.ts b/packages/canvas/src/store/shapeStore.ts index 1cf4c88..2d06e49 100644 --- a/packages/canvas/src/store/shapeStore.ts +++ b/packages/canvas/src/store/shapeStore.ts @@ -1,13 +1,25 @@ import * as Y from "yjs"; import type { YShape } from "@notux/types"; +import type { SupabaseClient } from "@supabase/supabase-js"; import { create } from "zustand"; import { getBoardDoc, getIndexedDbProvider, + getSupabaseProvider, + getAwareness, findPageMap, getPageMap, + type Awareness, } from "@notux/sync"; +// Realtime collaboration config, injected by the web app before initBoard. +// Absent (null) in local-only mode (no Supabase env), where the board still +// works fully against IndexedDB. +export interface RealtimeConfig { + client: SupabaseClient; + identity: { name: string; color: string }; +} + // One promise per boardId — concurrent callers for the same board share the // same init sequence (handles React StrictMode double-invoke). const _initPromises = new Map>(); @@ -21,8 +33,16 @@ interface ShapeStoreState { lastSaved: Date | null; // The active Y.Doc (null until initBoard resolves). _doc: Y.Doc | null; + // Yjs awareness for presence/cursors, set during initBoard when realtime is + // configured. Null in local-only mode. + _awareness: Awareness | null; + // Realtime config stashed by configureRealtime, consumed by initBoard. + _realtimeConfig: RealtimeConfig | null; _bump(): void; + // Inject (or clear) realtime collaboration config. Call before initBoard. + configureRealtime(config: RealtimeConfig | null): void; + // Must be called before any shape reads/writes. Idempotent per boardId. initBoard(boardId: string): Promise; @@ -108,11 +128,17 @@ export const useShapeStore = create((set, get) => ({ synced: false, lastSaved: null, _doc: null, + _awareness: null, + _realtimeConfig: null, _bump() { set((s) => ({ revision: s.revision + 1 })); }, + configureRealtime(config) { + set({ _realtimeConfig: config }); + }, + initBoard(boardId) { const existing = _initPromises.get(boardId); if (existing) { @@ -138,6 +164,20 @@ export const useShapeStore = create((set, get) => ({ const provider = getIndexedDbProvider(boardId, doc); await provider.whenSynced; set({ synced: true }); + + // Attach realtime sync AFTER IndexedDB has loaded, so the sync handshake + // advertises a complete local state vector and exchanges only a minimal + // diff with peers. Skipped entirely in local-only mode. + const cfg = get()._realtimeConfig; + if (cfg) { + const awareness = getAwareness(boardId, doc); + awareness.setLocalStateField("user", { + name: cfg.identity.name, + color: cfg.identity.color, + }); + getSupabaseProvider({ client: cfg.client, boardId, doc, awareness }); + set({ _awareness: awareness }); + } })(); _initPromises.set(boardId, promise); diff --git a/packages/sync/package.json b/packages/sync/package.json index 8198226..3222464 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -13,7 +13,10 @@ }, "dependencies": { "@notux/types": "workspace:*", + "@supabase/supabase-js": "^2.106.2", + "lib0": "^0.2.117", "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", "yjs": "^13.6.31" }, "devDependencies": { diff --git a/packages/sync/src/identity.ts b/packages/sync/src/identity.ts new file mode 100644 index 0000000..c880063 --- /dev/null +++ b/packages/sync/src/identity.ts @@ -0,0 +1,34 @@ +// Deterministic presence colors. The same seed (a Supabase user id or a +// per-tab guest id) always maps to the same color, so a given person shows up +// in one consistent color across every connected client. + +// A fixed palette of distinct, legible hues (Apple-system-ish accent colors). +const PALETTE = [ + "#ff3b30", // red + "#ff9500", // orange + "#ffcc00", // yellow + "#34c759", // green + "#00c7be", // teal + "#30b0c7", // cyan + "#5ac8fa", // light blue + "#007aff", // blue + "#5856d6", // indigo + "#af52de", // purple + "#ff2d55", // pink + "#a2845e", // brown +]; + +// FNV-1a — small, stable string hash. We only need an even-ish spread over the +// palette, not cryptographic quality. +function hashString(seed: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +export function colorForSeed(seed: string): string { + return PALETTE[hashString(seed) % PALETTE.length] ?? "#007aff"; +} diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 19ffcb2..d635701 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -1,3 +1,11 @@ export { getBoardDoc } from "./boardDoc"; export { getIndexedDbProvider } from "./indexedDbProvider"; export { findPageMap, getPageMap } from "./pageMap"; +export { + SupabaseProvider, + getSupabaseProvider, + getAwareness, +} from "./supabaseProvider"; +export type { SupabaseProviderOptions } from "./supabaseProvider"; +export { colorForSeed } from "./identity"; +export { Awareness } from "y-protocols/awareness"; diff --git a/packages/sync/src/supabaseProvider.ts b/packages/sync/src/supabaseProvider.ts new file mode 100644 index 0000000..71ea198 --- /dev/null +++ b/packages/sync/src/supabaseProvider.ts @@ -0,0 +1,239 @@ +import * as Y from "yjs"; +import * as encoding from "lib0/encoding"; +import * as decoding from "lib0/decoding"; +import { toBase64, fromBase64 } from "lib0/buffer"; +import * as syncProtocol from "y-protocols/sync"; +import { + Awareness, + encodeAwarenessUpdate, + applyAwarenessUpdate, + removeAwarenessStates, +} from "y-protocols/awareness"; +import type { + SupabaseClient, + RealtimeChannel, +} from "@supabase/supabase-js"; + +// Networks a Y.Doc over a Supabase Realtime broadcast channel scoped to one +// board. Implements the Yjs sync protocol (state-vector handshake so late +// joiners get missing updates from connected peers) plus awareness for +// presence/cursors. The Y.Doc remains the single source of truth — this is +// just another transport attached to it, alongside IndexedDB. +// +// Echo / undo handling: every update applied from the network uses THIS +// provider instance as the transaction origin. The `doc.on("update")` handler +// skips updates whose origin is `this` (no re-broadcast), and the canvas +// UndoManager — which tracks only null-origin (local) transactions — never +// captures them. + +// Message types, written as the first varUint of each payload. +const MSG_SYNC = 0; +const MSG_AWARENESS = 1; +const MSG_QUERY_AWARENESS = 2; + +const BROADCAST_EVENT = "y"; +// Supabase broadcast frames are size-limited; warn well before the ~256 KB cap. +const PAYLOAD_WARN_BYTES = 200_000; + +export interface SupabaseProviderOptions { + client: SupabaseClient; + boardId: string; + doc: Y.Doc; + awareness: Awareness; +} + +interface Envelope { + from: string; + b64: string; +} + +export class SupabaseProvider { + readonly boardId: string; + readonly doc: Y.Doc; + readonly awareness: Awareness; + synced = false; + + private client: SupabaseClient; + private channel: RealtimeChannel; + // Distinguishes our own broadcasts from peers' (belt-and-suspenders alongside + // the channel's `broadcast.self = false`). + private sessionId: string; + private destroyed = false; + + constructor(opts: SupabaseProviderOptions) { + this.client = opts.client; + this.boardId = opts.boardId; + this.doc = opts.doc; + this.awareness = opts.awareness; + this.sessionId = Math.random().toString(36).slice(2); + + this.channel = this.client.channel(`notux-board-${opts.boardId}`, { + config: { broadcast: { self: false } }, + }); + + this.channel.on("broadcast", { event: BROADCAST_EVENT }, ({ payload }) => { + this.handleMessage(payload as Envelope); + }); + + this.doc.on("update", this.onDocUpdate); + this.awareness.on("update", this.onAwarenessUpdate); + if (typeof window !== "undefined") { + window.addEventListener("pagehide", this.onUnload); + window.addEventListener("beforeunload", this.onUnload); + } + + this.channel.subscribe((status) => { + if (status === "SUBSCRIBED") this.onConnect(); + }); + } + + // --- outbound ----------------------------------------------------------- + + private broadcast(encoder: encoding.Encoder) { + if (this.destroyed) return; + const bytes = encoding.toUint8Array(encoder); + const b64 = toBase64(bytes); + if (b64.length > PAYLOAD_WARN_BYTES) { + // A board large enough to exceed the broadcast frame limit may have this + // message silently dropped. Late-join still works via the joiner's own + // IndexedDB; chunking is a future improvement. + console.warn( + `[notux/sync] broadcast payload ${b64.length}B exceeds safe size; ` + + "peers may not receive this update.", + ); + } + void this.channel.send({ + type: "broadcast", + event: BROADCAST_EVENT, + payload: { from: this.sessionId, b64 } satisfies Envelope, + }); + } + + private onConnect() { + // Step 1: advertise our state vector so peers reply with what we're missing. + const sync = encoding.createEncoder(); + encoding.writeVarUint(sync, MSG_SYNC); + syncProtocol.writeSyncStep1(sync, this.doc); + this.broadcast(sync); + + // Advertise our local awareness state and ask peers for theirs. + if (this.awareness.getLocalState() !== null) { + this.broadcastAwareness([this.awareness.clientID]); + } + const query = encoding.createEncoder(); + encoding.writeVarUint(query, MSG_QUERY_AWARENESS); + this.broadcast(query); + } + + private broadcastAwareness(clients: number[]) { + const enc = encoding.createEncoder(); + encoding.writeVarUint(enc, MSG_AWARENESS); + encoding.writeVarUint8Array(enc, encodeAwarenessUpdate(this.awareness, clients)); + this.broadcast(enc); + } + + private onDocUpdate = (update: Uint8Array, origin: unknown) => { + // Don't echo updates we just applied from the network. + if (origin === this) return; + const enc = encoding.createEncoder(); + encoding.writeVarUint(enc, MSG_SYNC); + syncProtocol.writeUpdate(enc, update); + this.broadcast(enc); + }; + + private onAwarenessUpdate = ( + changes: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown, + ) => { + if (origin === this) return; + const { added, updated, removed } = changes; + this.broadcastAwareness([...added, ...updated, ...removed]); + }; + + private onUnload = () => { + removeAwarenessStates(this.awareness, [this.awareness.clientID], "unload"); + }; + + // --- inbound ------------------------------------------------------------ + + private handleMessage(envelope: Envelope) { + if (!envelope || envelope.from === this.sessionId) return; + const decoder = decoding.createDecoder(fromBase64(envelope.b64)); + const type = decoding.readVarUint(decoder); + + switch (type) { + case MSG_SYNC: { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, MSG_SYNC); + // Applies any incoming update with `this` as origin and writes a reply + // (e.g. sync-step-2 in response to a peer's step-1) into `encoder`. + syncProtocol.readSyncMessage(decoder, encoder, this.doc, this); + if (!this.synced) { + this.synced = true; + } + // Only broadcast if the protocol produced a reply beyond the type byte. + if (encoding.length(encoder) > 1) this.broadcast(encoder); + break; + } + case MSG_AWARENESS: { + applyAwarenessUpdate( + this.awareness, + decoding.readVarUint8Array(decoder), + this, + ); + break; + } + case MSG_QUERY_AWARENESS: { + // A peer joined and wants current presence — re-send all known states. + const clients = Array.from(this.awareness.getStates().keys()); + if (clients.length > 0) this.broadcastAwareness(clients); + break; + } + default: + break; + } + } + + // --- teardown ----------------------------------------------------------- + + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.doc.off("update", this.onDocUpdate); + this.awareness.off("update", this.onAwarenessUpdate); + if (typeof window !== "undefined") { + window.removeEventListener("pagehide", this.onUnload); + window.removeEventListener("beforeunload", this.onUnload); + } + removeAwarenessStates(this.awareness, [this.awareness.clientID], "destroy"); + void this.client.removeChannel(this.channel); + _providers.delete(this.boardId); + _awarenessByBoard.delete(this.boardId); + } +} + +// Per-board singletons, mirroring getIndexedDbProvider. These outlive React +// component mounts (StrictMode double-invokes the effect), so we never open two +// channels or attach duplicate doc handlers for the same board. +const _providers = new Map(); +const _awarenessByBoard = new Map(); + +export function getAwareness(boardId: string, doc: Y.Doc): Awareness { + let awareness = _awarenessByBoard.get(boardId); + if (!awareness) { + awareness = new Awareness(doc); + _awarenessByBoard.set(boardId, awareness); + } + return awareness; +} + +export function getSupabaseProvider( + opts: SupabaseProviderOptions, +): SupabaseProvider { + let provider = _providers.get(opts.boardId); + if (!provider) { + provider = new SupabaseProvider(opts); + _providers.set(opts.boardId, provider); + } + return provider; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a0434e..fc99871 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@notux/types': specifier: workspace:* version: link:../types + '@supabase/supabase-js': + specifier: ^2.106.2 + version: 2.106.2 konva: specifier: ^9.3.16 version: 9.3.22 @@ -109,9 +112,18 @@ importers: '@notux/types': specifier: workspace:* version: link:../types + '@supabase/supabase-js': + specifier: ^2.106.2 + version: 2.106.2 + lib0: + specifier: ^0.2.117 + version: 0.2.117 y-indexeddb: specifier: ^9.0.12 version: 9.0.12(yjs@13.6.31) + y-protocols: + specifier: ^1.0.6 + version: 1.0.7(yjs@13.6.31) yjs: specifier: ^13.6.31 version: 13.6.31 @@ -838,6 +850,12 @@ packages: peerDependencies: yjs: ^13.0.0 + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1461,6 +1479,11 @@ snapshots: lib0: 0.2.117 yjs: 13.6.31 + y-protocols@1.0.7(yjs@13.6.31): + dependencies: + lib0: 0.2.117 + yjs: 13.6.31 + yallist@3.1.1: {} yjs@13.6.31: