diff --git a/apps/web/src/features/canvas/SaveStatus.tsx b/apps/web/src/features/canvas/SaveStatus.tsx
new file mode 100644
index 0000000..d0b2052
--- /dev/null
+++ b/apps/web/src/features/canvas/SaveStatus.tsx
@@ -0,0 +1,39 @@
+import { useShapeStore } from "@notux/canvas";
+
+function formatRelative(date: Date): string {
+ const secs = Math.floor((Date.now() - date.getTime()) / 1000);
+ if (secs < 5) return "just now";
+ if (secs < 60) return `${secs}s ago`;
+ const mins = Math.floor(secs / 60);
+ if (mins < 60) return `${mins}m ago`;
+ return `${Math.floor(mins / 60)}h ago`;
+}
+
+export function SaveStatus() {
+ const synced = useShapeStore((s) => s.synced);
+ const lastSaved = useShapeStore((s) => s.lastSaved);
+
+ if (!synced) return null;
+
+ return (
+
-
+
+
← Home
diff --git a/packages/canvas/package.json b/packages/canvas/package.json
index 7c97fd7..26db31c 100644
--- a/packages/canvas/package.json
+++ b/packages/canvas/package.json
@@ -12,11 +12,13 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
+ "@notux/sync": "workspace:*",
"@notux/types": "workspace:*",
"konva": "^9.3.16",
"nanoid": "^5.0.7",
"perfect-freehand": "^1.2.2",
"react-konva": "^18.2.10",
+ "yjs": "^13.6.20",
"zustand": "^4.5.5"
},
"peerDependencies": {
diff --git a/packages/canvas/src/CanvasStage.tsx b/packages/canvas/src/CanvasStage.tsx
index 818fcb7..c78142e 100644
--- a/packages/canvas/src/CanvasStage.tsx
+++ b/packages/canvas/src/CanvasStage.tsx
@@ -3,6 +3,7 @@ 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 { useUndoManager } from "./hooks/useUndoManager";
import { BackgroundLayer } from "./layers/BackgroundLayer";
import { OverlayLayer } from "./layers/OverlayLayer";
import { ShapesLayer } from "./layers/ShapesLayer";
@@ -54,6 +55,8 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro
const [viewport, setViewport] = useState({ x: 0, y: 0, scale: 1 });
const [spaceHeld, setSpaceHeld] = useState(false);
+ const { undo, redo } = useUndoManager(pageId);
+
const tool = useToolStore((s) => s.tool);
const selection = useToolStore((s) => s.selection);
const revision = useShapeStore((s) => s.revision);
@@ -141,7 +144,7 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro
toolRef.current = makeTool(tool);
}, [tool, buildToolContext]);
- // Space-to-pan + tool keyboard handlers (Delete, Escape).
+ // Space-to-pan, undo/redo, and tool keyboard handlers (Delete, Escape).
useEffect(() => {
function down(e: KeyboardEvent) {
if (isTypingTarget(e.target)) return;
@@ -150,6 +153,17 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro
setSpaceHeld(true);
return;
}
+ const mod = e.metaKey || e.ctrlKey;
+ if (mod && e.key === "z" && !e.shiftKey) {
+ e.preventDefault();
+ undo();
+ return;
+ }
+ if (mod && (e.key === "y" || (e.key === "z" && e.shiftKey))) {
+ e.preventDefault();
+ redo();
+ return;
+ }
const handler = toolRef.current.onKeyDown;
if (handler) handler(e, buildToolContext());
}
@@ -162,7 +176,7 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
- }, [buildToolContext]);
+ }, [buildToolContext, undo, redo]);
const panRef = useRef<{ active: boolean; lastX: number; lastY: number }>({
active: false,
diff --git a/packages/canvas/src/hooks/useUndoManager.ts b/packages/canvas/src/hooks/useUndoManager.ts
new file mode 100644
index 0000000..ddf2fb5
--- /dev/null
+++ b/packages/canvas/src/hooks/useUndoManager.ts
@@ -0,0 +1,48 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import * as Y from "yjs";
+import { getPageMap } from "@notux/sync";
+import { useShapeStore } from "../store/shapeStore";
+
+export function useUndoManager(pageId: string): {
+ undo(): void;
+ redo(): void;
+ canUndo: boolean;
+ canRedo: boolean;
+} {
+ const doc = useShapeStore((s) => s._doc);
+ const managerRef = useRef
(null);
+ const [canUndo, setCanUndo] = useState(false);
+ const [canRedo, setCanRedo] = useState(false);
+
+ useEffect(() => {
+ if (!doc) return;
+
+ const pageMap = getPageMap(doc, pageId);
+ const manager = new Y.UndoManager(pageMap);
+ managerRef.current = manager;
+
+ function update() {
+ setCanUndo(manager.undoStack.length > 0);
+ setCanRedo(manager.redoStack.length > 0);
+ }
+
+ manager.on("stack-item-added", update);
+ manager.on("stack-item-popped", update);
+ manager.on("stack-cleared", update);
+
+ return () => {
+ manager.off("stack-item-added", update);
+ manager.off("stack-item-popped", update);
+ manager.off("stack-cleared", update);
+ manager.destroy();
+ managerRef.current = null;
+ setCanUndo(false);
+ setCanRedo(false);
+ };
+ }, [doc, pageId]);
+
+ const undo = useCallback(() => managerRef.current?.undo(), []);
+ const redo = useCallback(() => managerRef.current?.redo(), []);
+
+ return { undo, redo, canUndo, canRedo };
+}
diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts
index 5dcda33..b65415e 100644
--- a/packages/canvas/src/index.ts
+++ b/packages/canvas/src/index.ts
@@ -1,4 +1,5 @@
export { CanvasStage } from "./CanvasStage";
+export { useUndoManager } from "./hooks/useUndoManager";
export { useShapeStore } from "./store/shapeStore";
export { useToolStore } from "./store/toolStore";
export type { ToolOptions } from "./store/toolStore";
diff --git a/packages/canvas/src/store/shapeStore.ts b/packages/canvas/src/store/shapeStore.ts
index 639b7c9..97c9b90 100644
--- a/packages/canvas/src/store/shapeStore.ts
+++ b/packages/canvas/src/store/shapeStore.ts
@@ -1,18 +1,31 @@
+import * as Y from "yjs";
import type { YShape } from "@notux/types";
import { create } from "zustand";
+import {
+ getBoardDoc,
+ getIndexedDbProvider,
+ findPageMap,
+ getPageMap,
+} from "@notux/sync";
-interface PageShapes {
- // Insertion-ordered map of shapeId -> shape.
- byId: Map;
-}
+// One promise per boardId — concurrent callers for the same board share the
+// same init sequence (handles React StrictMode double-invoke).
+const _initPromises = new Map>();
interface ShapeStoreState {
- // Bumps on every mutation so React selectors that read listShapes re-render.
+ // Bumps on every Yjs mutation so React selectors re-render.
revision: number;
- pages: Map;
- _ensurePage(pageId: string): PageShapes;
+ // True once IndexedDB has loaded existing data into the Y.Doc.
+ synced: boolean;
+ // Set ~300 ms after each document update (debounced).
+ lastSaved: Date | null;
+ // The active Y.Doc (null until initBoard resolves).
+ _doc: Y.Doc | null;
_bump(): void;
+ // Must be called before any shape reads/writes. Idempotent per boardId.
+ initBoard(boardId: string): Promise;
+
listShapes(pageId: string): YShape[];
getShape(pageId: string, id: string): YShape | undefined;
addShape(pageId: string, shape: YShape): void;
@@ -22,71 +35,107 @@ interface ShapeStoreState {
transact(fn: () => void): void;
}
-// In-memory store with a surface matching the eventual Yjs binding
-// (Y.Map>). The Yjs replacement in M3
-// implements this same interface, so renderers and tools stay untouched.
export const useShapeStore = create((set, get) => ({
revision: 0,
- pages: new Map(),
-
- _ensurePage(pageId) {
- let page = get().pages.get(pageId);
- if (!page) {
- page = { byId: new Map() };
- get().pages.set(pageId, page);
- }
- return page;
- },
+ synced: false,
+ lastSaved: null,
+ _doc: null,
_bump() {
set((s) => ({ revision: s.revision + 1 }));
},
+ initBoard(boardId) {
+ const existing = _initPromises.get(boardId);
+ if (existing) {
+ // Already initialising / done — ensure _doc is wired to this store.
+ set({ _doc: getBoardDoc(boardId) });
+ return existing;
+ }
+
+ const promise = (async () => {
+ const doc = getBoardDoc(boardId);
+ set({ _doc: doc, synced: false, lastSaved: null });
+
+ // Re-render whenever any shape in any page changes.
+ doc.getMap>("pages").observeDeep(() => get()._bump());
+
+ // Debounced lastSaved timestamp after each update.
+ let saveTimer: ReturnType | null = null;
+ doc.on("update", () => {
+ if (saveTimer !== null) clearTimeout(saveTimer);
+ saveTimer = setTimeout(() => set({ lastSaved: new Date() }), 300);
+ });
+
+ const provider = getIndexedDbProvider(boardId, doc);
+ await provider.whenSynced;
+ set({ synced: true });
+ })();
+
+ _initPromises.set(boardId, promise);
+ return promise;
+ },
+
listShapes(pageId) {
- const page = get().pages.get(pageId);
- if (!page) return [];
- return Array.from(page.byId.values());
+ const doc = get()._doc;
+ if (!doc) return [];
+ const pageMap = findPageMap(doc, pageId);
+ if (!pageMap) return [];
+ return Array.from(pageMap.values());
},
getShape(pageId, id) {
- return get().pages.get(pageId)?.byId.get(id);
+ const doc = get()._doc;
+ if (!doc) return undefined;
+ return findPageMap(doc, pageId)?.get(id);
},
addShape(pageId, shape) {
- const page = get()._ensurePage(pageId);
- page.byId.set(shape.id, shape);
- get()._bump();
+ const doc = get()._doc;
+ if (!doc) return;
+ const pageMap = getPageMap(doc, pageId);
+ doc.transact(() => pageMap.set(shape.id, shape));
},
updateShape(pageId, id, patch) {
- const page = get().pages.get(pageId);
- if (!page) return;
- const prev = page.byId.get(id);
+ const doc = get()._doc;
+ if (!doc) return;
+ const pageMap = findPageMap(doc, pageId);
+ if (!pageMap) return;
+ const prev = pageMap.get(id);
if (!prev) return;
- // Casting through unknown because Partial across a discriminated
- // union is wider than the actual variant — callers are responsible for
- // patching with kind-compatible fields.
- page.byId.set(id, { ...prev, ...patch } as unknown as YShape);
- get()._bump();
+ // Casting through unknown: Partial across a discriminated union is
+ // wider than any single variant — callers pass kind-compatible patches.
+ doc.transact(() =>
+ pageMap.set(id, { ...prev, ...patch } as unknown as YShape),
+ );
},
deleteShape(pageId, id) {
- const page = get().pages.get(pageId);
- if (!page) return;
- if (page.byId.delete(id)) get()._bump();
+ const doc = get()._doc;
+ if (!doc) return;
+ const pageMap = findPageMap(doc, pageId);
+ if (!pageMap) return;
+ doc.transact(() => pageMap.delete(id));
},
deleteShapes(pageId, ids) {
- const page = get().pages.get(pageId);
- if (!page) return;
- let changed = false;
- for (const id of ids) if (page.byId.delete(id)) changed = true;
- if (changed) get()._bump();
+ const doc = get()._doc;
+ if (!doc) return;
+ const pageMap = findPageMap(doc, pageId);
+ if (!pageMap) return;
+ doc.transact(() => {
+ for (const id of ids) pageMap.delete(id);
+ });
},
- // M2 stub — Yjs Y.transact wraps the same call in M3.
transact(fn) {
- fn();
+ const doc = get()._doc;
+ if (!doc) {
+ fn();
+ return;
+ }
+ doc.transact(fn);
},
}));
diff --git a/packages/canvas/tsconfig.json b/packages/canvas/tsconfig.json
index b0276ca..fc6b19d 100644
--- a/packages/canvas/tsconfig.json
+++ b/packages/canvas/tsconfig.json
@@ -3,6 +3,7 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
+ "@notux/sync": ["../sync/src"],
"@notux/types": ["../types/src"]
}
},
diff --git a/packages/sync/package.json b/packages/sync/package.json
index f335693..8198226 100644
--- a/packages/sync/package.json
+++ b/packages/sync/package.json
@@ -12,7 +12,9 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
- "@notux/types": "workspace:*"
+ "@notux/types": "workspace:*",
+ "y-indexeddb": "^9.0.12",
+ "yjs": "^13.6.31"
},
"devDependencies": {
"typescript": "^5.6.2"
diff --git a/packages/sync/src/boardDoc.ts b/packages/sync/src/boardDoc.ts
new file mode 100644
index 0000000..4585ca6
--- /dev/null
+++ b/packages/sync/src/boardDoc.ts
@@ -0,0 +1,12 @@
+import * as Y from "yjs";
+
+const _docs = new Map();
+
+export function getBoardDoc(boardId: string): Y.Doc {
+ let doc = _docs.get(boardId);
+ if (!doc) {
+ doc = new Y.Doc();
+ _docs.set(boardId, doc);
+ }
+ return doc;
+}
diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts
index dbb3822..19ffcb2 100644
--- a/packages/sync/src/index.ts
+++ b/packages/sync/src/index.ts
@@ -1 +1,3 @@
-export const SYNC_PACKAGE_VERSION = "0.0.0";
+export { getBoardDoc } from "./boardDoc";
+export { getIndexedDbProvider } from "./indexedDbProvider";
+export { findPageMap, getPageMap } from "./pageMap";
diff --git a/packages/sync/src/indexedDbProvider.ts b/packages/sync/src/indexedDbProvider.ts
new file mode 100644
index 0000000..27465b1
--- /dev/null
+++ b/packages/sync/src/indexedDbProvider.ts
@@ -0,0 +1,16 @@
+import * as Y from "yjs";
+import { IndexeddbPersistence } from "y-indexeddb";
+
+const _providers = new Map();
+
+export function getIndexedDbProvider(
+ boardId: string,
+ doc: Y.Doc,
+): IndexeddbPersistence {
+ let provider = _providers.get(boardId);
+ if (!provider) {
+ provider = new IndexeddbPersistence(`notux-board-${boardId}`, doc);
+ _providers.set(boardId, provider);
+ }
+ return provider;
+}
diff --git a/packages/sync/src/pageMap.ts b/packages/sync/src/pageMap.ts
new file mode 100644
index 0000000..db3d9b3
--- /dev/null
+++ b/packages/sync/src/pageMap.ts
@@ -0,0 +1,21 @@
+import * as Y from "yjs";
+import type { YShape } from "@notux/types";
+
+/** Returns the page's Y.Map if it exists, or null (no write). */
+export function findPageMap(
+ doc: Y.Doc,
+ pageId: string,
+): Y.Map | null {
+ return doc.getMap>("pages").get(pageId) ?? null;
+}
+
+/** Returns the page's Y.Map, creating it inside a transaction if absent. */
+export function getPageMap(doc: Y.Doc, pageId: string): Y.Map {
+ const pages = doc.getMap>("pages");
+ const existing = pages.get(pageId);
+ if (existing) return existing;
+
+ const pageMap = new Y.Map();
+ doc.transact(() => pages.set(pageId, pageMap));
+ return pageMap;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0827313..a39111e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -63,6 +63,9 @@ importers:
packages/canvas:
dependencies:
+ '@notux/sync':
+ specifier: workspace:*
+ version: link:../sync
'@notux/types':
specifier: workspace:*
version: link:../types
@@ -78,6 +81,9 @@ importers:
react-konva:
specifier: ^18.2.10
version: 18.2.16(@types/react@18.3.29)(konva@9.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ yjs:
+ specifier: ^13.6.20
+ version: 13.6.31
zustand:
specifier: ^4.5.5
version: 4.5.7(@types/react@18.3.29)(react@18.3.1)
@@ -103,6 +109,12 @@ importers:
'@notux/types':
specifier: workspace:*
version: link:../types
+ y-indexeddb:
+ specifier: ^9.0.12
+ version: 9.0.12(yjs@13.6.31)
+ yjs:
+ specifier: ^13.6.31
+ version: 13.6.31
devDependencies:
typescript:
specifier: ^5.6.2
@@ -608,6 +620,9 @@ packages:
resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==}
engines: {node: '>=20.0.0'}
+ isomorphic.js@0.2.5:
+ resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
+
its-fine@1.2.5:
resolution: {integrity: sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==}
peerDependencies:
@@ -629,6 +644,11 @@ packages:
konva@9.3.22:
resolution: {integrity: sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==}
+ lib0@0.2.117:
+ resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==}
+ engines: {node: '>=16'}
+ hasBin: true
+
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -768,9 +788,19 @@ packages:
terser:
optional: true
+ y-indexeddb@9.0.12:
+ resolution: {integrity: sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==}
+ engines: {node: '>=16.0.0', npm: '>=8.0.0'}
+ peerDependencies:
+ yjs: ^13.0.0
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+ yjs@13.6.31:
+ resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==}
+ engines: {node: '>=16.0.0', npm: '>=8.0.0'}
+
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
@@ -1206,6 +1236,8 @@ snapshots:
iceberg-js@0.8.1: {}
+ isomorphic.js@0.2.5: {}
+
its-fine@1.2.5(@types/react@18.3.29)(react@18.3.1):
dependencies:
'@types/react-reconciler': 0.28.9(@types/react@18.3.29)
@@ -1221,6 +1253,10 @@ snapshots:
konva@9.3.22: {}
+ lib0@0.2.117:
+ dependencies:
+ isomorphic.js: 0.2.5
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -1350,8 +1386,17 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ y-indexeddb@9.0.12(yjs@13.6.31):
+ dependencies:
+ lib0: 0.2.117
+ yjs: 13.6.31
+
yallist@3.1.1: {}
+ yjs@13.6.31:
+ dependencies:
+ lib0: 0.2.117
+
zustand@4.5.7(@types/react@18.3.29)(react@18.3.1):
dependencies:
use-sync-external-store: 1.6.0(react@18.3.1)