Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/features/canvas/useIdentity.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
11 changes: 10 additions & 1 deletion apps/web/src/routes/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions packages/canvas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 49 additions & 11 deletions packages/canvas/src/CanvasStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<number | null>(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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
const native = evt.nativeEvent;
Expand Down Expand Up @@ -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()}
Expand All @@ -393,15 +438,8 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro
>
<BackgroundLayer viewport={viewport} width={size.w} height={size.h} />
<ShapesLayer ref={shapesLayerRef} shapes={shapes} selection={selection} />
{tool === "select" && (
<TransformLayer
selection={selection}
pageId={pageId}
revision={revision}
shapesLayerRef={shapesLayerRef}
/>
)}
<OverlayLayer draft={draft} selectedShapes={overlayShapes} viewport={viewport} />
<OverlayLayer draft={draft} selectedShapes={selectedShapes} viewport={viewport} />
<PresenceLayer awareness={awareness} viewport={viewport} />
</Stage>
<TextEditorOverlay
viewport={viewport}
Expand Down
7 changes: 7 additions & 0 deletions packages/canvas/src/hooks/useAwareness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { Awareness } from "@notux/sync";
import { useShapeStore } from "../store/shapeStore";

// The active Yjs awareness instance, or null in local-only mode (no realtime).
export function useAwareness(): Awareness | null {
return useShapeStore((s) => s._awareness);
}
54 changes: 54 additions & 0 deletions packages/canvas/src/hooks/useRemoteCursors.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 4 additions & 0 deletions packages/canvas/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
51 changes: 51 additions & 0 deletions packages/canvas/src/layers/PresenceLayer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Layer listening={false}>
{cursors.map((c) => {
if (!c.cursor) return null;
return (
<Group key={c.clientID} x={c.cursor.x} y={c.cursor.y} scaleX={inv} scaleY={inv}>
<Line
points={ARROW_POINTS}
closed
fill={c.color}
stroke="white"
strokeWidth={1}
lineJoin="round"
/>
<Label x={14} y={10}>
<Tag fill={c.color} cornerRadius={4} />
<Text
text={c.name}
fontSize={12}
fontStyle="600"
fill="white"
padding={4}
/>
</Label>
</Group>
);
})}
</Layer>
);
}
40 changes: 40 additions & 0 deletions packages/canvas/src/store/shapeStore.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<void>>();
Expand All @@ -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<void>;

Expand Down Expand Up @@ -108,11 +128,17 @@ export const useShapeStore = create<ShapeStoreState>((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) {
Expand All @@ -138,6 +164,20 @@ export const useShapeStore = create<ShapeStoreState>((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);
Expand Down
Loading
Loading