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 ( +
+ {lastSaved ? `Saved ${formatRelative(lastSaved)}` : "Saved"} +
+ ); +} diff --git a/apps/web/src/routes/Board.tsx b/apps/web/src/routes/Board.tsx index 6d0a268..c3db482 100644 --- a/apps/web/src/routes/Board.tsx +++ b/apps/web/src/routes/Board.tsx @@ -1,14 +1,53 @@ +import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { CanvasStage } from "@notux/canvas"; +import { CanvasStage, useShapeStore } from "@notux/canvas"; +import { SaveStatus } from "../features/canvas/SaveStatus"; import { ToolPalette } from "../features/canvas/ToolPalette"; export default function Board() { const { boardId } = useParams<{ boardId: string }>(); + const [ready, setReady] = useState(false); + + useEffect(() => { + if (!boardId) return; + setReady(false); + useShapeStore + .getState() + .initBoard(boardId) + .then(() => setReady(true)); + }, [boardId]); + + if (!ready) { + return ( +
+
+ +
+ ); + } 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)