From 3d13d30c19527a532216abf5fc04fea31570ccba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 15:59:58 +0000 Subject: [PATCH] feat(M6): PDF + image import Add the ability to import images and PDFs onto a board, replacing the placeholder asset renderer with a real one. Entry points: an Import button in the tool palette and drag-and-drop onto the canvas. - Images become a single YAssetRef; PDFs become one YAssetRef per page laid out in a grid (all pages sharing one asset, discriminated by pageIndex). - Bytes are stored in a public Supabase Storage bucket (board-assets) at the deterministic path /; a best-effort row is written to the existing assets table. Import is disabled when Supabase isn't configured. - New asset loader resolves a paintable bitmap per (assetId, pageIndex) with in-memory LRU caches, downloading bytes from Storage and lazily rasterizing PDF pages with pdf.js (loaded as a separate chunk via dynamic import). - AssetRefRenderer paints a Konva Image (loading/error placeholders), so M5 transform/rotate/z-order/lock/opacity apply unchanged. A whole import is one transaction, so a single undo reverts it. - Add 0002_assets_storage.sql: board-assets bucket + free-for-all storage RLS mirroring the public-board model. https://claude.ai/code/session_011TMH7eYBb5huhrHTzQf9Kb --- apps/web/package.json | 1 + apps/web/src/features/canvas/ToolPalette.tsx | 29 ++- apps/web/src/routes/Board.tsx | 9 +- packages/canvas/package.json | 1 + packages/canvas/src/CanvasStage.tsx | 40 ++++ packages/canvas/src/assets/assetLoader.ts | 158 ++++++++++++++++ packages/canvas/src/assets/importFiles.ts | 178 ++++++++++++++++++ packages/canvas/src/assets/pdf.ts | 48 +++++ packages/canvas/src/assets/storage.ts | 41 ++++ packages/canvas/src/hooks/useAssetBitmap.ts | 34 ++++ packages/canvas/src/ids.ts | 4 + packages/canvas/src/index.ts | 1 + .../canvas/src/renderers/AssetRefRenderer.tsx | 42 ++++- packages/canvas/src/store/assetStore.ts | 111 +++++++++++ packages/canvas/src/vite-env.d.ts | 8 + pnpm-lock.yaml | 132 +++++++++++++ supabase/migrations/0002_assets_storage.sql | 53 ++++++ 17 files changed, 879 insertions(+), 11 deletions(-) create mode 100644 packages/canvas/src/assets/assetLoader.ts create mode 100644 packages/canvas/src/assets/importFiles.ts create mode 100644 packages/canvas/src/assets/pdf.ts create mode 100644 packages/canvas/src/assets/storage.ts create mode 100644 packages/canvas/src/hooks/useAssetBitmap.ts create mode 100644 packages/canvas/src/store/assetStore.ts create mode 100644 packages/canvas/src/vite-env.d.ts create mode 100644 supabase/migrations/0002_assets_storage.sql diff --git a/apps/web/package.json b/apps/web/package.json index da3441e..781faaf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@notux/ui": "workspace:*", "@supabase/supabase-js": "^2.45.4", "konva": "^9.3.16", + "pdfjs-dist": "^4.7.76", "react": "^18.3.1", "react-dom": "^18.3.1", "react-konva": "^18.2.10", diff --git a/apps/web/src/features/canvas/ToolPalette.tsx b/apps/web/src/features/canvas/ToolPalette.tsx index 8688710..28e0c49 100644 --- a/apps/web/src/features/canvas/ToolPalette.tsx +++ b/apps/web/src/features/canvas/ToolPalette.tsx @@ -1,5 +1,6 @@ +import { useRef } from "react"; import type { ToolKind } from "@notux/types"; -import { useToolStore } from "@notux/canvas"; +import { useAssetStore, useToolStore } from "@notux/canvas"; import { COLORS, SIZES } from "./palette"; interface ToolDef { @@ -27,6 +28,8 @@ export function ToolPalette() { const setTool = useToolStore((s) => s.setTool); const setColor = useToolStore((s) => s.setColor); const setSize = useToolStore((s) => s.setSize); + const canImport = useAssetStore((s) => s.canImport); + const fileRef = useRef(null); return (
@@ -46,6 +49,30 @@ export function ToolPalette() { {t.glyph} ))} + + { + const files = e.target.files; + if (files && files.length > 0) { + void useAssetStore.getState().importAtCenter(files); + } + e.target.value = ""; + }} + />
{COLORS.map((c) => ( diff --git a/apps/web/src/routes/Board.tsx b/apps/web/src/routes/Board.tsx index 0ce6389..ffbcbb5 100644 --- a/apps/web/src/routes/Board.tsx +++ b/apps/web/src/routes/Board.tsx @@ -1,6 +1,11 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { CanvasStage, DEFAULT_PAGE_ID, useShapeStore } from "@notux/canvas"; +import { + CanvasStage, + DEFAULT_PAGE_ID, + useAssetStore, + useShapeStore, +} from "@notux/canvas"; import { SaveStatus } from "../features/canvas/SaveStatus"; import { SelectionInspector } from "../features/canvas/SelectionInspector"; import { ToolPalette } from "../features/canvas/ToolPalette"; @@ -21,6 +26,8 @@ export default function Board() { useShapeStore .getState() .configureRealtime(client ? { client, identity } : null); + // Bytes live in Supabase Storage; import is disabled when client is null. + useAssetStore.getState().configure({ boardId, client }); useShapeStore .getState() .initBoard(boardId) diff --git a/packages/canvas/package.json b/packages/canvas/package.json index cb569db..f1923b5 100644 --- a/packages/canvas/package.json +++ b/packages/canvas/package.json @@ -17,6 +17,7 @@ "@supabase/supabase-js": "^2.106.2", "konva": "^9.3.16", "nanoid": "^5.0.7", + "pdfjs-dist": "^4.7.76", "perfect-freehand": "^1.2.2", "react-konva": "^18.2.10", "yjs": "^13.6.20", diff --git a/packages/canvas/src/CanvasStage.tsx b/packages/canvas/src/CanvasStage.tsx index 3a029b5..0221ddd 100644 --- a/packages/canvas/src/CanvasStage.tsx +++ b/packages/canvas/src/CanvasStage.tsx @@ -10,6 +10,7 @@ import { OverlayLayer } from "./layers/OverlayLayer"; import { PresenceLayer } from "./layers/PresenceLayer"; import { ShapesLayer } from "./layers/ShapesLayer"; import { TransformLayer } from "./layers/TransformLayer"; +import { useAssetStore } from "./store/assetStore"; import { useDraftStore } from "./store/draftStore"; import { DEFAULT_PAGE_ID } from "./store/pageStore"; import { useShapeStore } from "./store/shapeStore"; @@ -101,6 +102,18 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro return () => ro.disconnect(); }, []); + // Keep the asset store aware of the current viewport/size/page/author so a + // button-triggered import (which has no drop point) lands at the canvas centre + // and is attributed to this canvas's author. + useEffect(() => { + useAssetStore.getState().setCanvasInfo({ + viewport, + size, + pageId, + authorId: authorIdRef.current, + }); + }, [viewport, size, pageId]); + // Hit test — converts world point to container coords and asks Konva. const hitTestWorld = useCallback( (p: { x: number; y: number }): YShape | undefined => { @@ -348,6 +361,31 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro } }, []); + // File drag-and-drop import. dragover must preventDefault for drop to fire. + const onDragOver = useCallback((evt: React.DragEvent) => { + if (!evt.dataTransfer.types.includes("Files")) return; + evt.preventDefault(); + evt.dataTransfer.dropEffect = "copy"; + }, []); + + const onDrop = useCallback( + (evt: React.DragEvent) => { + const files = evt.dataTransfer.files; + if (!files || files.length === 0) return; + evt.preventDefault(); + if (!useAssetStore.getState().canImport) { + console.warn("Import requires Supabase to be configured"); + return; + } + const rect = containerRef.current?.getBoundingClientRect(); + const sx = evt.clientX - (rect?.left ?? 0); + const sy = evt.clientY - (rect?.top ?? 0); + const world = screenToWorld(viewport, sx, sy); + void useAssetStore.getState().importAt(files, world); + }, + [viewport], + ); + // Suppress browser-level zoom on wheel by attaching a non-passive listener. // React's onWheel handler is passive in newer React versions and can't // preventDefault, so we add a native listener for that purpose. @@ -423,6 +461,8 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro onPointerCancel={onPointerUp} onPointerLeave={onPointerLeave} onWheel={onWheel} + onDragOver={onDragOver} + onDrop={onDrop} onDoubleClick={onDoubleClick} onContextMenu={(e) => e.preventDefault()} > diff --git a/packages/canvas/src/assets/assetLoader.ts b/packages/canvas/src/assets/assetLoader.ts new file mode 100644 index 0000000..df73c5d --- /dev/null +++ b/packages/canvas/src/assets/assetLoader.ts @@ -0,0 +1,158 @@ +import type { PDFDocumentProxy } from "pdfjs-dist"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { useAssetStore } from "../store/assetStore"; +import { downloadAssetBlob } from "./storage"; + +export type AssetBitmap = HTMLImageElement | HTMLCanvasElement | ImageBitmap; + +const BITMAP_CACHE_LIMIT = 40; +const BLOB_CACHE_LIMIT = 8; +const PDF_DOC_CACHE_LIMIT = 3; +// Target CSS-px width a rasterized PDF page is rendered at (scaled by DPR). +const PDF_RASTER_WIDTH = 1024; + +const bitmapCache = new Map(); +const inflight = new Map>(); +const blobCache = new Map>(); +const pdfDocCache = new Map>(); + +function key(assetId: string, pageIndex: number | null): string { + return `${assetId}:${pageIndex ?? "img"}`; +} + +// Insertion-order Map doubles as an LRU: re-set on access, evict the oldest key +// once over the limit. +function lruSet(map: Map, k: string, v: V, limit: number): void { + map.delete(k); + map.set(k, v); + if (map.size > limit) { + const oldest = map.keys().next().value as string | undefined; + if (oldest !== undefined) map.delete(oldest); + } +} + +function requireCtx(): { boardId: string; client: SupabaseClient } { + const s = useAssetStore.getState(); + if (!s._boardId || !s._client) { + throw new Error("Asset subsystem not configured with Supabase"); + } + return { boardId: s._boardId, client: s._client }; +} + +function resolveBlob(assetId: string): Promise { + const existing = blobCache.get(assetId); + if (existing) { + lruSet(blobCache, assetId, existing, BLOB_CACHE_LIMIT); + return existing; + } + const { boardId, client } = requireCtx(); + const p = downloadAssetBlob(client, boardId, assetId).catch((err) => { + blobCache.delete(assetId); // allow retry on transient failure + throw err; + }); + lruSet(blobCache, assetId, p, BLOB_CACHE_LIMIT); + return p; +} + +function setPdfDoc(assetId: string, p: Promise): void { + while (pdfDocCache.size >= PDF_DOC_CACHE_LIMIT) { + const oldest = pdfDocCache.keys().next().value as string | undefined; + if (oldest === undefined) break; + const dead = pdfDocCache.get(oldest); + pdfDocCache.delete(oldest); + void dead?.then((d) => d.destroy()).catch(() => {}); + } + pdfDocCache.set(assetId, p); +} + +function resolvePdfDoc(assetId: string): Promise { + const existing = pdfDocCache.get(assetId); + if (existing) return existing; + const p = (async () => { + const blob = await resolveBlob(assetId); + const buf = await blob.arrayBuffer(); + const { loadPdf } = await import("./pdf"); + return loadPdf(buf); + })().catch((err) => { + pdfDocCache.delete(assetId); + throw err; + }); + setPdfDoc(assetId, p); + return p; +} + +async function decodeImage(blob: Blob): Promise { + // createImageBitmap on SVG is unreliable cross-browser; decode via . + if (blob.type === "image/svg+xml" || typeof createImageBitmap !== "function") { + const url = URL.createObjectURL(blob); + try { + const img = new Image(); + img.src = url; + await img.decode(); + return img; + } finally { + URL.revokeObjectURL(url); + } + } + return createImageBitmap(blob); +} + +// Seed the cache with a bitmap decoded at import time so a freshly placed shape +// paints without a Storage round-trip. +export function primeBitmap( + assetId: string, + pageIndex: number | null, + bmp: AssetBitmap, +): void { + lruSet(bitmapCache, key(assetId, pageIndex), bmp, BITMAP_CACHE_LIMIT); +} + +export function primeBlob(assetId: string, blob: Blob): void { + lruSet(blobCache, assetId, Promise.resolve(blob), BLOB_CACHE_LIMIT); +} + +export function primePdfDoc(assetId: string, doc: PDFDocumentProxy): void { + setPdfDoc(assetId, Promise.resolve(doc)); +} + +export function loadAssetBitmap( + assetId: string, + pageIndex: number | null, +): Promise { + const k = key(assetId, pageIndex); + const cached = bitmapCache.get(k); + if (cached) { + lruSet(bitmapCache, k, cached, BITMAP_CACHE_LIMIT); + return Promise.resolve(cached); + } + const pending = inflight.get(k); + if (pending) return pending; + + const p = (async (): Promise => { + let bmp: AssetBitmap; + if (pageIndex === null) { + bmp = await decodeImage(await resolveBlob(assetId)); + } else { + const doc = await resolvePdfDoc(assetId); + const { rasterizePage } = await import("./pdf"); + bmp = await rasterizePage(doc, pageIndex, PDF_RASTER_WIDTH); + } + lruSet(bitmapCache, k, bmp, BITMAP_CACHE_LIMIT); + return bmp; + })(); + + inflight.set(k, p); + void p.catch(() => undefined).then(() => inflight.delete(k)); + return p; +} + +// Drop every cache (e.g. when leaving a board). +export function clearAssetCaches(): void { + bitmapCache.clear(); + inflight.clear(); + blobCache.clear(); + for (const p of pdfDocCache.values()) { + void p.then((d) => d.destroy()).catch(() => undefined); + } + pdfDocCache.clear(); +} diff --git a/packages/canvas/src/assets/importFiles.ts b/packages/canvas/src/assets/importFiles.ts new file mode 100644 index 0000000..26fe615 --- /dev/null +++ b/packages/canvas/src/assets/importFiles.ts @@ -0,0 +1,178 @@ +import type { YAssetRef, AssetKind } from "@notux/types"; +import { newShapeId } from "../ids"; +import { useShapeStore } from "../store/shapeStore"; +import { useToolStore } from "../store/toolStore"; +import { useAssetStore } from "../store/assetStore"; +import { + primeBitmap, + primeBlob, + primePdfDoc, + type AssetBitmap, +} from "./assetLoader"; + +const MAX_FILE_BYTES = 25 * 1024 * 1024; // matches Supabase storage file_size_limit +const MAX_IMAGE_SIZE = 640; // world units, longest side of a placed image +const PDF_CELL_W = 360; // world units, grid cell width per page +const PDF_GRID_GAP = 24; // world units between cells / stacked imports +const PDF_GRID_COLS = 4; + +export interface ImportResult { + created: string[]; + errors: { file: string; reason: string }[]; +} + +function classify(file: File): AssetKind | null { + if (file.type === "application/pdf") return "pdf"; + if (file.type.startsWith("image/")) return "image"; + return null; +} + +async function decodeImageFile( + file: File, +): Promise<{ bitmap: AssetBitmap; w: number; h: number }> { + // SVG: createImageBitmap is unreliable; measure via (fall back to a + // default box when the SVG declares no intrinsic size). + if (file.type === "image/svg+xml" || typeof createImageBitmap !== "function") { + const url = URL.createObjectURL(file); + try { + const img = new Image(); + img.src = url; + await img.decode(); + return { + bitmap: img, + w: img.naturalWidth || 320, + h: img.naturalHeight || 320, + }; + } finally { + URL.revokeObjectURL(url); + } + } + const bitmap = await createImageBitmap(file); + return { bitmap, w: bitmap.width, h: bitmap.height }; +} + +function fitImage(w: number, h: number): { w: number; h: number } { + const longest = Math.max(w, h); + if (longest <= MAX_IMAGE_SIZE || longest === 0) return { w, h }; + const s = MAX_IMAGE_SIZE / longest; + return { w: Math.round(w * s), h: Math.round(h * s) }; +} + +function makeShape( + authorId: string, + assetId: string, + pageIndex: number | null, + x: number, + y: number, + w: number, + h: number, +): YAssetRef { + return { + id: newShapeId(), + author: authorId, + kind: "asset", + assetId, + pageIndex, + x, + y, + w, + h, + rot: 0, + }; +} + +// Import images and PDFs at `world` (a drop point, or the viewport centre for +// the toolbar button). Images become one shape; a PDF becomes one shape per +// page laid out in a grid. All shapes are committed in a single transaction so +// one undo reverts the whole import. +export async function importFiles( + files: FileList | File[], + world: { x: number; y: number }, + pageId: string, + authorId: string, +): Promise { + const list = Array.from(files); + const assetStore = useAssetStore.getState(); + const errors: ImportResult["errors"] = []; + const shapes: YAssetRef[] = []; + let originY = world.y; // advances so multiple files don't stack exactly + + for (const file of list) { + const kind = classify(file); + if (!kind) { + errors.push({ file: file.name, reason: "Unsupported file type" }); + continue; + } + if (file.size > MAX_FILE_BYTES) { + errors.push({ file: file.name, reason: "File exceeds 25 MB limit" }); + continue; + } + try { + if (kind === "image") { + const dec = await decodeImageFile(file); + const asset = await assetStore.ingest(file, { + kind: "image", + pageCount: null, + }); + primeBlob(asset.id, file); + primeBitmap(asset.id, null, dec.bitmap); + const { w, h } = fitImage(dec.w, dec.h); + shapes.push( + makeShape(authorId, asset.id, null, world.x - w / 2, originY - h / 2, w, h), + ); + originY += h + PDF_GRID_GAP; + } else { + const { loadPdf, pdfInfo } = await import("./pdf"); + const buf = await file.arrayBuffer(); + const doc = await loadPdf(buf); + const info = await pdfInfo(doc); + if (info.pageCount < 1) throw new Error("PDF has no pages"); + const asset = await assetStore.ingest(file, { + kind: "pdf", + pageCount: info.pageCount, + }); + primeBlob(asset.id, file); + primePdfDoc(asset.id, doc); // reuse the parsed doc for rasterization + + const cols = Math.min(info.pageCount, PDF_GRID_COLS); + const cellH = PDF_CELL_W * (info.firstPage.h / info.firstPage.w); + const rows = Math.ceil(info.pageCount / cols); + const gridW = cols * PDF_CELL_W + (cols - 1) * PDF_GRID_GAP; + const gridH = rows * cellH + (rows - 1) * PDF_GRID_GAP; + const ox = world.x - gridW / 2; + const oy = originY; + for (let i = 0; i < info.pageCount; i++) { + const c = i % cols; + const r = Math.floor(i / cols); + shapes.push( + makeShape( + authorId, + asset.id, + i, + ox + c * (PDF_CELL_W + PDF_GRID_GAP), + oy + r * (cellH + PDF_GRID_GAP), + PDF_CELL_W, + cellH, + ), + ); + } + originY = oy + gridH + PDF_GRID_GAP; + } + } catch (err) { + errors.push({ + file: file.name, + reason: err instanceof Error ? err.message : "Import failed", + }); + } + } + + if (shapes.length > 0) { + const store = useShapeStore.getState(); + store.transact(() => { + for (const shape of shapes) store.addShape(pageId, shape); + }); + useToolStore.getState().setSelection(shapes.map((s) => s.id)); + } + + return { created: shapes.map((s) => s.id), errors }; +} diff --git a/packages/canvas/src/assets/pdf.ts b/packages/canvas/src/assets/pdf.ts new file mode 100644 index 0000000..b573c90 --- /dev/null +++ b/packages/canvas/src/assets/pdf.ts @@ -0,0 +1,48 @@ +import * as pdfjs from "pdfjs-dist"; +import type { PDFDocumentProxy } from "pdfjs-dist"; +// Vite emits a hashed URL for the worker bundle; this is the CSP-friendly way to +// wire the pdf.js worker (no CDN, no cross-origin worker). This module is only +// ever reached via dynamic import(), so pdf.js stays out of the initial bundle. +import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; + +pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; + +// Clamp the longest rasterized side so large pages don't blow up memory. +const MAX_RASTER_PX = 2048; + +export async function loadPdf(data: ArrayBuffer): Promise { + return pdfjs.getDocument({ data }).promise; +} + +export async function pdfInfo( + doc: PDFDocumentProxy, +): Promise<{ pageCount: number; firstPage: { w: number; h: number } }> { + const page = await doc.getPage(1); + const vp = page.getViewport({ scale: 1 }); + return { pageCount: doc.numPages, firstPage: { w: vp.width, h: vp.height } }; +} + +// Rasterize a single page (0-based) to a canvas sized for `targetWidthPx` CSS +// pixels at the current device pixel ratio, clamped to MAX_RASTER_PX. +export async function rasterizePage( + doc: PDFDocumentProxy, + pageIndex: number, + targetWidthPx: number, +): Promise { + const page = await doc.getPage(pageIndex + 1); // pdf.js pages are 1-based + const base = page.getViewport({ scale: 1 }); + const dpr = + typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; + let scale = (Math.max(1, targetWidthPx) * dpr) / base.width; + const longest = Math.max(base.width, base.height) * scale; + if (longest > MAX_RASTER_PX) scale *= MAX_RASTER_PX / longest; + + const viewport = page.getViewport({ scale }); + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(viewport.width); + canvas.height = Math.ceil(viewport.height); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2D canvas context unavailable"); + await page.render({ canvasContext: ctx, viewport }).promise; + return canvas; +} diff --git a/packages/canvas/src/assets/storage.ts b/packages/canvas/src/assets/storage.ts new file mode 100644 index 0000000..18fe448 --- /dev/null +++ b/packages/canvas/src/assets/storage.ts @@ -0,0 +1,41 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; + +// Public Supabase Storage bucket holding imported PDF/image bytes. Created by +// the 0002_assets_storage.sql migration with free-for-all RLS on public boards. +export const BOARD_ASSETS_BUCKET = "board-assets"; + +// Deterministic object key for an asset. Because it is derived purely from the +// board + asset id, the renderer can locate bytes from a YAssetRef alone — no +// separate metadata lookup is needed. +export function assetPath(boardId: string, assetId: string): string { + return `${boardId}/${assetId}`; +} + +export async function uploadAsset( + client: SupabaseClient, + boardId: string, + assetId: string, + file: File, +): Promise { + const { error } = await client.storage + .from(BOARD_ASSETS_BUCKET) + .upload(assetPath(boardId, assetId), file, { + contentType: file.type || "application/octet-stream", + upsert: true, + }); + if (error) throw error; +} + +export async function downloadAssetBlob( + client: SupabaseClient, + boardId: string, + assetId: string, +): Promise { + const { data, error } = await client.storage + .from(BOARD_ASSETS_BUCKET) + .download(assetPath(boardId, assetId)); + if (error || !data) { + throw error ?? new Error("Asset download returned no data"); + } + return data; +} diff --git a/packages/canvas/src/hooks/useAssetBitmap.ts b/packages/canvas/src/hooks/useAssetBitmap.ts new file mode 100644 index 0000000..640f6aa --- /dev/null +++ b/packages/canvas/src/hooks/useAssetBitmap.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import { loadAssetBitmap, type AssetBitmap } from "../assets/assetLoader"; + +export type AssetBitmapState = + | { status: "loading" } + | { status: "ready"; bitmap: AssetBitmap } + | { status: "error" }; + +// Resolve the paintable bitmap for an asset (image) or a specific PDF page. +// Returns a small state machine the AssetRefRenderer maps to a Konva Image or a +// loading/error placeholder. +export function useAssetBitmap( + assetId: string, + pageIndex: number | null, +): AssetBitmapState { + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + loadAssetBitmap(assetId, pageIndex) + .then((bitmap) => { + if (!cancelled) setState({ status: "ready", bitmap }); + }) + .catch(() => { + if (!cancelled) setState({ status: "error" }); + }); + return () => { + cancelled = true; + }; + }, [assetId, pageIndex]); + + return state; +} diff --git a/packages/canvas/src/ids.ts b/packages/canvas/src/ids.ts index ee392da..09eaf38 100644 --- a/packages/canvas/src/ids.ts +++ b/packages/canvas/src/ids.ts @@ -7,3 +7,7 @@ export function newShapeId(): string { export function newAuthorId(): string { return nanoid(8); } + +export function newAssetId(): string { + return nanoid(12); +} diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts index 795ed89..dc2b99d 100644 --- a/packages/canvas/src/index.ts +++ b/packages/canvas/src/index.ts @@ -7,5 +7,6 @@ export { useShapeStore } from "./store/shapeStore"; export type { RealtimeConfig } from "./store/shapeStore"; export { useToolStore } from "./store/toolStore"; export type { ToolOptions } from "./store/toolStore"; +export { useAssetStore } from "./store/assetStore"; export { DEFAULT_PAGE_ID } from "./store/pageStore"; export type { ShapeStore } from "./store/shapeStore"; diff --git a/packages/canvas/src/renderers/AssetRefRenderer.tsx b/packages/canvas/src/renderers/AssetRefRenderer.tsx index 7ffbd81..cd37bb8 100644 --- a/packages/canvas/src/renderers/AssetRefRenderer.tsx +++ b/packages/canvas/src/renderers/AssetRefRenderer.tsx @@ -1,31 +1,55 @@ import type { YAssetRef } from "@notux/types"; -import { Group, Rect, Text } from "react-konva"; +import { Group, Image as KonvaImage, Rect, Text } from "react-konva"; +import { useAssetBitmap } from "../hooks/useAssetBitmap"; interface Props { shape: YAssetRef; selected?: boolean; } -// Placeholder renderer for M2. M4–M5 (PDF/image import) replaces this with a -// real renderer that loads the asset from Supabase Storage. +// M6 (PDF/image import) renderer. Loads the asset bitmap from Supabase Storage +// (via the asset loader cache) and paints it with a Konva Image. Drawn in local +// coords — the ShapesLayer Group carries x/y/rotation, so M5 transform/rotate/ +// z-order/lock/opacity all apply unchanged. export function AssetRefRenderer({ shape, selected }: Props) { - // Local coords; the ShapesLayer Group carries x/y/rotation. + const state = useAssetBitmap(shape.assetId, shape.pageIndex); + const sel: { + shadowColor?: string; + shadowBlur?: number; + shadowOpacity?: number; + } = selected + ? { shadowColor: "#5ac8fa", shadowBlur: 12, shadowOpacity: 0.9 } + : {}; + + if (state.status === "ready") { + return ( + + + + ); + } + + // Loading / error fall back to the dashed placeholder look. return ( ; + // Import at a specific world point (e.g. a drag-drop location). + importAt(files: FileList | File[], world: { x: number; y: number }): Promise; + // Import via the toolbar button: place at the current viewport centre. + importAtCenter(files: FileList | File[]): Promise; +} + +export const useAssetStore = create((set, get) => ({ + _boardId: null, + _client: null, + _canvas: null, + canImport: false, + + configure({ boardId, client }) { + set({ _boardId: boardId, _client: client, canImport: client !== null }); + }, + + setCanvasInfo(info) { + set({ _canvas: info }); + }, + + async ingest(file, decoded) { + const boardId = get()._boardId; + const client = get()._client; + if (!boardId || !client) { + throw new Error("Import requires Supabase to be configured"); + } + const assetId = newAssetId(); + await uploadAsset(client, boardId, assetId, file); + const asset: Asset = { + id: assetId, + boardId, + kind: decoded.kind, + storagePath: assetPath(boardId, assetId), + originalFilename: file.name, + pageCount: decoded.pageCount, + createdAt: new Date().toISOString(), + }; + // Best-effort durable record (matches the `assets` table scaffolding). + // Rendering resolves bytes from the deterministic storage path, so this row + // is not required for the app to work — swallow failures. + void client + .from("assets") + .insert({ + id: asset.id, + board_id: asset.boardId, + kind: asset.kind, + storage_path: asset.storagePath, + original_filename: asset.originalFilename, + page_count: asset.pageCount, + }) + .then(({ error }) => { + if (error) console.warn("asset metadata insert failed:", error.message); + }); + return asset; + }, + + async importAt(files, world) { + const canvas = get()._canvas; + if (!canvas) return; + // Dynamic import keeps the import pipeline (and its lazy pdf.js) out of the + // store module graph and avoids a static import cycle. + const { importFiles } = await import("../assets/importFiles"); + await importFiles(files, world, canvas.pageId, canvas.authorId); + }, + + async importAtCenter(files) { + const canvas = get()._canvas; + if (!canvas) return; + const world = screenToWorld( + canvas.viewport, + canvas.size.w / 2, + canvas.size.h / 2, + ); + await get().importAt(files, world); + }, +})); diff --git a/packages/canvas/src/vite-env.d.ts b/packages/canvas/src/vite-env.d.ts new file mode 100644 index 0000000..4b94cf5 --- /dev/null +++ b/packages/canvas/src/vite-env.d.ts @@ -0,0 +1,8 @@ +// Ambient declaration so this package's standalone `tsc --noEmit` accepts +// Vite's `?url` asset imports (used for the pdf.js worker in assets/pdf.ts). +// The web app gets the same declaration from `vite/client`; the canvas package +// does not depend on Vite, so it is declared locally here. +declare module "*?url" { + const src: string; + export default src; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc99871..009df18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: konva: specifier: ^9.3.16 version: 9.3.22 + pdfjs-dist: + specifier: ^4.7.76 + version: 4.10.38 react: specifier: ^18.3.1 version: 18.3.1 @@ -78,6 +81,9 @@ importers: nanoid: specifier: ^5.0.7 version: 5.1.11 + pdfjs-dist: + specifier: ^4.7.76 + version: 4.10.38 perfect-freehand: specifier: ^1.2.2 version: 1.2.3 @@ -401,6 +407,76 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + '@remix-run/router@1.23.2': resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} engines: {node: '>=14.0.0'} @@ -712,6 +788,10 @@ packages: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} + pdfjs-dist@4.10.38: + resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==} + engines: {node: '>=20'} + perfect-freehand@1.2.3: resolution: {integrity: sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==} @@ -1089,6 +1169,54 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + '@remix-run/router@1.23.2': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -1351,6 +1479,10 @@ snapshots: node-releases@2.0.46: {} + pdfjs-dist@4.10.38: + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + perfect-freehand@1.2.3: {} picocolors@1.1.1: {} diff --git a/supabase/migrations/0002_assets_storage.sql b/supabase/migrations/0002_assets_storage.sql new file mode 100644 index 0000000..d3e137e --- /dev/null +++ b/supabase/migrations/0002_assets_storage.sql @@ -0,0 +1,53 @@ +-- NotUX M6: public Storage bucket for imported PDF/image bytes. +-- Mirrors the free-for-all public-board model (see 0001_init.sql): anyone may +-- read/write objects whose first path segment is a public board's id. +-- Path convention: / (matches Asset.storage_path). + +insert into storage.buckets (id, name, public) +values ('board-assets', 'board-assets', true) +on conflict (id) do nothing; + +-- (storage.foldername(name))[1] is the first path segment, i.e. the board id. +create policy "board_assets_read_public" + on storage.objects for select + using ( + bucket_id = 'board-assets' + and exists ( + select 1 from boards b + where b.id = (storage.foldername(name))[1]::uuid + and b.is_public + ) + ); + +create policy "board_assets_insert_public" + on storage.objects for insert + with check ( + bucket_id = 'board-assets' + and exists ( + select 1 from boards b + where b.id = (storage.foldername(name))[1]::uuid + and b.is_public + ) + ); + +create policy "board_assets_update_public" + on storage.objects for update + using ( + bucket_id = 'board-assets' + and exists ( + select 1 from boards b + where b.id = (storage.foldername(name))[1]::uuid + and b.is_public + ) + ); + +create policy "board_assets_delete_public" + on storage.objects for delete + using ( + bucket_id = 'board-assets' + and exists ( + select 1 from boards b + where b.id = (storage.foldername(name))[1]::uuid + and b.is_public + ) + );