-
-
Notifications
You must be signed in to change notification settings - Fork 9
feat(projects): per-project canvas board (tldraw + SSE) #270
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 27 commits
8bc4551
6b59e74
582aaa6
29f4d72
61155c6
d704365
f93fc14
12563a7
f1e613b
cdd9b01
7d559e5
acac2e5
b02ba0a
e4b7e59
d97e875
4fd6e3b
834010b
a6e4463
25e7c03
d974232
2b63848
de987e9
044378c
45a38df
35ce519
b84d071
d4674b4
4cefae1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { canvasApi } from "../canvas/canvas-api"; | ||
|
|
||
| beforeEach(() => { | ||
| global.fetch = vi.fn(); | ||
| }); | ||
|
|
||
| describe("canvasApi", () => { | ||
| it("listElements GETs the right URL", async () => { | ||
| (fetch as any).mockResolvedValue({ | ||
| ok: true, | ||
| json: async () => ({ elements: [] }), | ||
| }); | ||
| const r = await canvasApi.listElements("prj-1"); | ||
| expect(fetch).toHaveBeenCalledWith("/api/projects/prj-1/canvas/elements"); | ||
| expect(r).toEqual([]); | ||
| }); | ||
|
|
||
| it("addElement POSTs body", async () => { | ||
| (fetch as any).mockResolvedValue({ | ||
| ok: true, | ||
| json: async () => ({ element: { id: "cve-1", kind: "note" } }), | ||
| }); | ||
| const r = await canvasApi.addElement("prj-1", { | ||
| kind: "note", x: 1, y: 2, w: 3, h: 4, payload: { text: "x" }, | ||
| }); | ||
| expect(r.id).toBe("cve-1"); | ||
| const call = (fetch as any).mock.calls[0]; | ||
| expect(call[0]).toBe("/api/projects/prj-1/canvas/elements"); | ||
| expect(call[1].method).toBe("POST"); | ||
| }); | ||
|
|
||
| it("deleteElement returns true on 204", async () => { | ||
| (fetch as any).mockResolvedValue({ ok: true, status: 204 }); | ||
| const r = await canvasApi.deleteElement("prj-1", "cve-1"); | ||
| expect(r).toBe(true); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { createCanvasStore } from "../canvas/canvas-store"; | ||
|
|
||
| describe("canvas-store", () => { | ||
| it("seeds elements", () => { | ||
| const store = createCanvasStore(); | ||
| store.getState().seed([ | ||
| { id: "cve-1", kind: "note", x: 0, y: 0, w: 1, h: 1, | ||
| rotation: 0, z_index: 0, payload: {}, project_id: "p", | ||
| author_kind: "user", author_id: "u", | ||
| created_at: 0, updated_at: 0, deleted_at: null } as any, | ||
| ]); | ||
| expect(store.getState().elements["cve-1"].kind).toBe("note"); | ||
| }); | ||
|
|
||
| it("upsert replaces by id", () => { | ||
| const store = createCanvasStore(); | ||
| store.getState().upsert({ id: "x", x: 1 } as any); | ||
| store.getState().upsert({ id: "x", x: 99 } as any); | ||
| expect(store.getState().elements["x"].x).toBe(99); | ||
| }); | ||
|
|
||
| it("remove drops the element", () => { | ||
| const store = createCanvasStore(); | ||
| store.getState().upsert({ id: "x" } as any); | ||
| store.getState().remove("x"); | ||
| expect(store.getState().elements["x"]).toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| import { useEffect, useMemo, useRef } from "react"; | ||
| import { Tldraw, Editor, createTLStore, defaultShapeUtils, TLShape } from "@tldraw/tldraw"; | ||
| import "@tldraw/tldraw/tldraw.css"; | ||
|
|
||
| import { canvasApi, CanvasElement } from "./canvas-api"; | ||
| import { createCanvasStore } from "./canvas-store"; | ||
| import { subscribeCanvasStream } from "./canvas-sse"; | ||
| import { TaosNoteShapeUtil } from "./shapes/NoteShape"; | ||
| import { TaosLinkShapeUtil } from "./shapes/LinkShape"; | ||
| import { TaosImageShapeUtil } from "./shapes/ImageShape"; | ||
|
|
||
| interface CanvasBoardProps { | ||
| projectId: string; | ||
| projectSlug: string; | ||
| } | ||
|
|
||
| const CUSTOM_SHAPE_UTILS = [TaosNoteShapeUtil, TaosLinkShapeUtil, TaosImageShapeUtil]; | ||
|
|
||
| export function CanvasBoard({ projectId, projectSlug }: CanvasBoardProps) { | ||
| const cacheRef = useRef(createCanvasStore()); | ||
| const editorRef = useRef<Editor | null>(null); | ||
|
|
||
| // In tldraw v4, shapeUtils are passed to <Tldraw>, not createTLStore | ||
| const store = useMemo( | ||
| () => createTLStore({ defaultName: `canvas-${projectId}` }), | ||
| [projectId], | ||
| ); | ||
|
|
||
| // Initial load + SSE subscription | ||
| useEffect(() => { | ||
| let cancelled = false; | ||
| (async () => { | ||
| const elements = await canvasApi.listElements(projectId); | ||
| if (cancelled) return; | ||
| cacheRef.current.getState().seed(elements); | ||
| hydrateEditor(editorRef.current, elements, projectSlug); | ||
| })(); | ||
| const unsub = subscribeCanvasStream(projectId, cacheRef.current); | ||
|
Comment on lines
+30
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid clobbering live SSE updates with the initial fetch result. The subscription is opened before 🤖 Prompt for AI Agents |
||
| return () => { | ||
| cancelled = true; | ||
| unsub(); | ||
| }; | ||
| }, [projectId, projectSlug]); | ||
|
|
||
| // Re-hydrate on local cache changes (SSE-driven updates from agents) | ||
| useEffect(() => { | ||
| const unsub = cacheRef.current.subscribe((s) => { | ||
| hydrateEditor(editorRef.current, Object.values(s.elements), projectSlug); | ||
| }); | ||
| return unsub; | ||
| }, [projectSlug]); | ||
|
|
||
| return ( | ||
| <div style={{ position: "relative", width: "100%", height: "100%" }}> | ||
| <Tldraw | ||
| store={store} | ||
| shapeUtils={[...defaultShapeUtils, ...CUSTOM_SHAPE_UTILS] as any} | ||
| onMount={(editor) => { | ||
| editorRef.current = editor; | ||
| // Send local user edits to backend | ||
| editor.store.listen( | ||
| (entry) => { | ||
| if (entry.source !== "user") return; | ||
| const added = entry.changes.added as Record<string, TLShape | undefined>; | ||
| for (const shape of Object.values(added)) { | ||
| if (!shape || !shape.id.startsWith("shape:")) continue; | ||
| pushAdd(projectId, shape).catch(console.warn); | ||
| } | ||
| const updated = entry.changes.updated as Record<string, [TLShape, TLShape] | undefined>; | ||
| for (const pair of Object.values(updated)) { | ||
| if (!pair) continue; | ||
| const next = pair[1]; | ||
| if (!next.id.startsWith("shape:")) continue; | ||
| pushUpdate(projectId, next).catch(console.warn); | ||
| } | ||
| const removed = entry.changes.removed as Record<string, TLShape | undefined>; | ||
| for (const shape of Object.values(removed)) { | ||
| if (!shape || !shape.id.startsWith("shape:")) continue; | ||
| const elementId = shape.id.replace(/^shape:/, ""); | ||
| canvasApi.deleteElement(projectId, elementId).catch(console.warn); | ||
| } | ||
| }, | ||
| { source: "user", scope: "document" }, | ||
| ); | ||
| }} | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function hydrateEditor( | ||
| editor: Editor | null, | ||
| elements: CanvasElement[], | ||
| projectSlug: string, | ||
| ) { | ||
| if (!editor) return; | ||
| editor.run(() => { | ||
| const wantedIds = new Set(elements.map((e) => `shape:${e.id}`)); | ||
| const existing = editor.getCurrentPageShapes(); | ||
| const toRemove = existing.filter((s) => !wantedIds.has(s.id) && s.id.toString().startsWith("shape:")); | ||
| if (toRemove.length) editor.deleteShapes(toRemove.map((s) => s.id) as any); | ||
|
|
||
| for (const el of elements) { | ||
| const id = `shape:${el.id}` as TLShape["id"]; | ||
| const existingShape = editor.getShape(id); | ||
| const newShape = elementToShape(el, projectSlug); | ||
| if (existingShape) { | ||
| editor.updateShape(newShape); | ||
| } else { | ||
| editor.createShape(newShape); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| function elementToShape(el: CanvasElement, projectSlug: string): any { | ||
| const baseProps = { | ||
| w: el.w, h: el.h, | ||
| taos_kind: el.kind, | ||
| taos_payload: el.payload, | ||
| taos_author_id: el.author_id, | ||
| taos_author_kind: el.author_kind, | ||
| }; | ||
| return { | ||
| id: `shape:${el.id}`, | ||
| type: shapeType(el.kind), | ||
| x: el.x, y: el.y, rotation: el.rotation, | ||
| props: el.kind === "image" | ||
| ? { ...baseProps, project_slug: projectSlug } | ||
| : baseProps, | ||
| }; | ||
|
Comment on lines
+116
to
+131
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Persist stacking order too.
Also applies to: 141-162 🤖 Prompt for AI Agents
Also applies to: 141-151 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| function shapeType(kind: string): string { | ||
| if (kind === "note") return "taos-note"; | ||
| if (kind === "link") return "taos-link"; | ||
| if (kind === "image") return "taos-image"; | ||
| return "geo"; | ||
| } | ||
|
|
||
| async function pushAdd(projectId: string, shape: TLShape) { | ||
| if (!shape.id.toString().startsWith("shape:")) return; | ||
| const props: any = shape.props; | ||
| await canvasApi.addElement(projectId, { | ||
| id: shape.id.toString().replace(/^shape:/, ""), | ||
| kind: (props.taos_kind ?? "user_shape") as any, | ||
| x: shape.x, y: shape.y, | ||
| w: props.w ?? 100, h: props.h ?? 100, | ||
| rotation: shape.rotation, | ||
| payload: props.taos_payload ?? { tldraw_shape: shape }, | ||
| }); | ||
| } | ||
|
|
||
| async function pushUpdate(projectId: string, shape: TLShape) { | ||
| const elementId = shape.id.toString().replace(/^shape:/, ""); | ||
| const props: any = shape.props; | ||
| await canvasApi.updateElement(projectId, elementId, { | ||
| x: shape.x, y: shape.y, | ||
| w: props.w, h: props.h, | ||
| rotation: shape.rotation, | ||
| payload: props.taos_payload, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { CanvasBoard } from "./CanvasBoard"; | ||
|
|
||
| export function CanvasView({ | ||
| projectId, projectSlug, | ||
| }: { projectId: string; projectSlug: string }) { | ||
| return ( | ||
| <div style={{ height: "calc(100vh - 100px)", padding: 0 }}> | ||
| <CanvasBoard projectId={projectId} projectSlug={projectSlug} /> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle async failures and await refresh to prevent stale member state.
Both action handlers can reject before refresh runs, and
refresh()is currently fire-and-forget. This can leave permission/member UI out of sync.Diff
<input type="checkbox" checked={!!m.can_edit_canvas} onChange={async (e) => { - await canvasApi.setPermission(project.id, m.member_id, e.target.checked); - refresh(); - onChanged(); + const checked = e.currentTarget.checked; + try { + await canvasApi.setPermission(project.id, m.member_id, checked); + } finally { + await refresh(); + onChanged(); + } }} /> @@ <button type="button" onClick={async () => { - await projectsApi.members.remove(project.id, m.member_id); - refresh(); - onChanged(); + try { + await projectsApi.members.remove(project.id, m.member_id); + } finally { + await refresh(); + onChanged(); + } }} className="text-xs text-red-400 hover:underline" aria-label={`Remove ${m.member_id}`}🤖 Prompt for AI Agents