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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 28 additions & 1 deletion apps/web/src/features/canvas/ToolPalette.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<HTMLInputElement>(null);

return (
<div className="tool-palette" role="toolbar" aria-label="Drawing tools">
Expand All @@ -46,6 +49,30 @@ export function ToolPalette() {
<span aria-hidden>{t.glyph}</span>
</button>
))}
<button
type="button"
className="tool-palette__chip"
onClick={() => fileRef.current?.click()}
disabled={!canImport}
title={canImport ? "Import image or PDF" : "Import requires Supabase"}
aria-label="Import image or PDF"
>
<span aria-hidden>⬆</span>
</button>
<input
ref={fileRef}
type="file"
accept="image/*,application/pdf"
multiple
style={{ display: "none" }}
onChange={(e) => {
const files = e.target.files;
if (files && files.length > 0) {
void useAssetStore.getState().importAtCenter(files);
}
e.target.value = "";
}}
/>
</div>
<div className="tool-palette__row tool-palette__row--swatches">
{COLORS.map((c) => (
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/routes/Board.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/canvas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions packages/canvas/src/CanvasStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
if (!evt.dataTransfer.types.includes("Files")) return;
evt.preventDefault();
evt.dataTransfer.dropEffect = "copy";
}, []);

const onDrop = useCallback(
(evt: React.DragEvent<HTMLDivElement>) => {
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.
Expand Down Expand Up @@ -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()}
>
Expand Down
158 changes: 158 additions & 0 deletions packages/canvas/src/assets/assetLoader.ts
Original file line number Diff line number Diff line change
@@ -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<string, AssetBitmap>();
const inflight = new Map<string, Promise<AssetBitmap>>();
const blobCache = new Map<string, Promise<Blob>>();
const pdfDocCache = new Map<string, Promise<PDFDocumentProxy>>();

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<V>(map: Map<string, V>, 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<Blob> {
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<PDFDocumentProxy>): 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<PDFDocumentProxy> {
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<AssetBitmap> {
// createImageBitmap on SVG is unreliable cross-browser; decode via <img>.
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<AssetBitmap> {
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<AssetBitmap> => {
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();
}
Loading
Loading