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
19 changes: 1 addition & 18 deletions apps/web/src/features/board/snapshotsApi.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,12 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { bytesToHexBytea, hexByteaToBytes } from "@notux/sync";

export interface NamedSnapshot {
id: string;
label: string;
createdAt: string;
}

// PostgREST reads/writes a `bytea` column as a hex string in the "\x.." format.
// Encoding the Yjs update as hex avoids needing a server-side base64 decode RPC
// and works against the stock schema.
function bytesToHexBytea(bytes: Uint8Array): string {
let hex = "\\x";
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
return hex;
}

function hexByteaToBytes(s: string): Uint8Array {
const hex = s.startsWith("\\x") ? s.slice(2) : s;
const out = new Uint8Array(hex.length >> 1);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return out;
}

export async function saveNamedSnapshot(
client: SupabaseClient,
boardId: string,
Expand Down
79 changes: 79 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# NotUX Enhancement Roadmap

Re-grounded against the live repo (currently at **M9**). The earlier strategy doc assumed
the repo sat at M4; in fact `perfect-freehand`, Yjs + a custom Supabase-broadcast provider,
`y-indexeddb`, PDF/image import, audio + YouTube/gdrive embeds, named snapshots, and
selection/transform/z-order/lock are all already shipped. This roadmap keeps the recommended
direction but re-prioritizes around the genuine, verified gaps.

Strategic posture is unchanged: keep the Konva + Yjs + Supabase Realtime + Liquid Glass
stack; treat Excalidraw/tldraw/OpenBoard as reference implementations, not dependencies.

## M10 — Durable Yjs autosave persistence ✅ (this PR)

**The critical architectural fix.** Board state previously lived only in each client's
IndexedDB and the ephemeral Realtime broadcast — a fresh client (no IndexedDB) or a
late-joiner arriving after every peer had left got an **empty board**.

The `snapshots` table already supports a single self-compacting `autosave` row per board
(unique partial index + RLS insert/update for public boards), so **no migration and no
compaction Edge Function are needed** — we diverge from the original append-log proposal in
favor of the simpler model the schema already encodes.

- `packages/sync/src/autosave.ts`: `loadAutosave` (fetch + `Y.applyUpdate` **merge**, not the
destructive `restoreSnapshot`), `startAutosave` (debounced ~2s / max-wait ~10s writer with a
best-effort `pagehide`/`visibilitychange` flush, update-or-insert the autosave row), and the
shared `bytea` hex helpers.
- `packages/canvas/src/store/shapeStore.ts` (`initBoard`): after IndexedDB hydrate + realtime
attach, `loadAutosave` then `startAutosave` — only when a Supabase client is configured
(local-only mode is unchanged).
- `apps/web/src/features/board/snapshotsApi.ts`: reuses the hex helpers from `@notux/sync`.

*Exit criterion:* a board survives all users leaving; a fresh client and an offline late-joiner
both load the last state and converge.

## M11 — Native ink feel

Feature-detect `getCoalescedEvents()` (fall back to plain `pointermove`); add a
`{ desynchronized: true }` context on a **dedicated wet-ink Konva layer** split out of the
shared `OverlayLayer` so only it redraws per move; `pointerType`-based palm rejection in
`PenTool`. Raw points + pressure are already stored — keep that. Tune `strokeGeometry.ts`
`streamline`/`smoothing` for the "hot elbows" artifact. *Exit:* smooth Apple-Pencil strokes on
iPad Safari, crisp at any zoom; only the wet-ink layer redraws during a stroke.

## M12 — Scale the canvas

Add `rbush` as a spatial index in `shapeStore` for viewport culling + hit-testing (replaces the
linear scans in `CanvasStage.hitTestWorld` / `rectIntersect`); cull off-screen shapes in
`ShapesLayer`; set Konva `perfectDrawEnabled(false)` + `shadowForStrokeEnabled(false)` on shape
nodes. *Exit:* 60 fps panning a 5,000+ shape board.

## M13 — Editing polish

Refine the area eraser toward a scribble-overlay + geometry hit-test feel; auto-sizing stickies;
color picker palette + custom + recents (in Living Cosmos).

## M14 — Content breadth

URL bookmarks via a cached `unfurl` Supabase Edge Function (genuine gap). Verify PDF import is
lazily rasterized per page.

## M15 — Embeds registry

Generalize the existing YouTube/gdrive embeds into a tldraw-style `EmbedDefinition` registry;
export embeds as placeholders (iframes can't be captured by canvas export).

## M16 — Cross-platform packaging

Add `vite-plugin-pwa` + full manifest + icons (genuine gap — `index.html` has only a
`theme-color`); wrap with Capacitor gated by `Capacitor.isNativePlatform()`; macOS as an
installed PWA. Do not introduce React Native/Flutter.

## Design track — Living Cosmos re-skin

Replace the Apple-system tokens in `packages/ui/src/styles.css` (`--accent:#5ac8fa`/`#0a84ff`)
with the Living Cosmos palette (midnight `#0a0e1a`, nebula `#3d4fd6`, rain `#4db8c8`, moss,
sandstone), the Inter + IBM Plex Mono fonts, the organic radius grammar (12/16/999/8), and the
`--ease-organic`/`--ease-spring` motion curves; set `index.html` `theme-color` to `#0a0e1a`.
Keep accessibility guards (`prefers-reduced-motion`, focus rings using rain). Only touches the
token layer the components already consume, so it can land early.
15 changes: 15 additions & 0 deletions packages/canvas/src/store/shapeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
getAwareness,
findPageMap,
getPageMap,
loadAutosave,
startAutosave,
LOCAL_ORIGIN,
type Awareness,
} from "@notux/sync";
Expand All @@ -25,6 +27,10 @@ export interface RealtimeConfig {
// same init sequence (handles React StrictMode double-invoke).
const _initPromises = new Map<string, Promise<void>>();

// Detaches the durable-autosave listeners for the previously initialised board.
// Stops the old loop before a new board's loop starts when switching boards.
let _stopAutosave: (() => void) | null = null;

interface ShapeStoreState {
// Bumps on every Yjs mutation so React selectors re-render.
revision: number;
Expand Down Expand Up @@ -178,6 +184,15 @@ export const useShapeStore = create<ShapeStoreState>((set, get) => ({
});
getSupabaseProvider({ client: cfg.client, boardId, doc, awareness });
set({ _awareness: awareness });

// Durable persistence: merge the server-side autosave snapshot so a
// fresh client (no IndexedDB) or a late-joiner arriving after every peer
// has left still loads the board, then keep that row up to date. CRDT
// merge means this is safe to run after IndexedDB + realtime attach.
_stopAutosave?.();
_stopAutosave = null;
await loadAutosave(cfg.client, boardId, doc);
_stopAutosave = startAutosave(cfg.client, boardId, doc);
}
})();

Expand Down
125 changes: 125 additions & 0 deletions packages/sync/src/autosave.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import * as Y from "yjs";
import type { SupabaseClient } from "@supabase/supabase-js";
import { encodeSnapshot } from "./snapshots";

// PostgREST reads/writes a `bytea` column as a hex string in the "\x.." format.
// Encoding the Yjs update as hex avoids needing a server-side base64 decode RPC
// and works against the stock schema. Shared with the named-snapshot API in the
// web app so there's a single source for the bytea wire format.
export function bytesToHexBytea(bytes: Uint8Array): string {
let hex = "\\x";
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
return hex;
}

export function hexByteaToBytes(s: string): Uint8Array {
const hex = s.startsWith("\\x") ? s.slice(2) : s;
const out = new Uint8Array(hex.length >> 1);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return out;
}

// Fetch the board's durable `autosave` snapshot from Postgres and MERGE it into
// the live doc. Uses Y.applyUpdate (a CRDT merge), NOT restoreSnapshot — the
// latter is a destructive time-travel replace used by the named-snapshot panel.
// Merging is order-independent and convergent, so it's safe to call after the
// IndexedDB hydrate and after the realtime provider has attached. This is what
// lets a fresh client (no IndexedDB) or a late-joiner arriving after every peer
// has left still load the last board state.
export async function loadAutosave(
client: SupabaseClient,
boardId: string,
doc: Y.Doc,
): Promise<void> {
const { data, error } = await client
.from("snapshots")
.select("ydoc")
.eq("board_id", boardId)
.eq("kind", "autosave")
.maybeSingle();
if (error || !data?.ydoc) return;
Y.applyUpdate(doc, hexByteaToBytes(data.ydoc as string));
}

// Write the full board state to the single per-board `autosave` row. The schema
// keeps one self-compacting row (Y.encodeStateAsUpdate already compacts), so
// there's no append-log to compact — we update-or-insert in place. PostgREST
// can't upsert onto the partial unique index (board_id where kind='autosave'),
// so we branch: try update first, insert only if no row existed.
async function writeAutosave(
client: SupabaseClient,
boardId: string,
doc: Y.Doc,
): Promise<void> {
const ydoc = bytesToHexBytea(encodeSnapshot(doc));
const { data, error } = await client
.from("snapshots")
.update({ ydoc })
.eq("board_id", boardId)
.eq("kind", "autosave")
.select("id");
if (error) return;
if (!data || data.length === 0) {
await client.from("snapshots").insert({ board_id: boardId, kind: "autosave", ydoc });
}
}

const DEBOUNCE_MS = 2_000;
const MAX_WAIT_MS = 10_000;

// Persist the doc to Postgres on every change, debounced (~2s) with a max-wait
// (~10s) so a long uninterrupted editing burst still flushes periodically.
// Best-effort flush on pagehide / tab-hide so the last edits aren't lost on
// close. Returns a stop() that detaches all listeners and timers.
export function startAutosave(
client: SupabaseClient,
boardId: string,
doc: Y.Doc,
): () => void {
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let firstChangeAt: number | null = null;
let stopped = false;

const flush = () => {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
firstChangeAt = null;
if (!stopped) void writeAutosave(client, boardId, doc);
};

const onUpdate = () => {
const now = Date.now();
if (firstChangeAt === null) firstChangeAt = now;
if (now - firstChangeAt >= MAX_WAIT_MS) {
flush();
return;
}
if (debounceTimer !== null) clearTimeout(debounceTimer);
debounceTimer = setTimeout(flush, DEBOUNCE_MS);
};

const onHide = () => {
if (document.visibilityState === "hidden") flush();
};

doc.on("update", onUpdate);
const hasWindow = typeof window !== "undefined";
if (hasWindow) {
window.addEventListener("pagehide", flush);
document.addEventListener("visibilitychange", onHide);
}

return () => {
stopped = true;
if (debounceTimer !== null) clearTimeout(debounceTimer);
doc.off("update", onUpdate);
if (hasWindow) {
window.removeEventListener("pagehide", flush);
document.removeEventListener("visibilitychange", onHide);
}
};
}
6 changes: 6 additions & 0 deletions packages/sync/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ export type { SupabaseProviderOptions } from "./supabaseProvider";
export { colorForSeed } from "./identity";
export { LOCAL_ORIGIN } from "./origin";
export { encodeSnapshot, restoreSnapshot } from "./snapshots";
export {
loadAutosave,
startAutosave,
bytesToHexBytea,
hexByteaToBytes,
} from "./autosave";
export { Awareness } from "y-protocols/awareness";
Loading