diff --git a/editor/grida-canvas-hosted/playground/playground.tsx b/editor/grida-canvas-hosted/playground/playground.tsx index 02b5bc2956..8cd5338685 100644 --- a/editor/grida-canvas-hosted/playground/playground.tsx +++ b/editor/grida-canvas-hosted/playground/playground.tsx @@ -101,6 +101,7 @@ import { WindowGlobalCurrentEditorProvider } from "@/grida-canvas-react/devtools import { EditorYSyncPlugin } from "@/grida-canvas/plugins/yjs"; import { Editor } from "@/grida-canvas/editor"; import { PlayerAvatar } from "@/components/multiplayer/avatar"; +import grida from "@grida/schema"; import colors, { neutral_colors, randomcolorname, @@ -466,6 +467,21 @@ export default function CanvasPlayground({ const bytes = await opfs.get("document.grida").read(); if (bytes && !cancelled) { const loadedDocument = io.GRID.decode(bytes); + + // Load images from OPFS (images/.) + const images: Record = {}; + const names = await opfs.listImages(); + for (const name of names) { + const base = name.split("/").pop() ?? name; + const hash = base.includes(".") ? base.split(".")[0]! : base; + try { + const img = await opfs.readImage(name); + images[hash] = img; + } catch (e) { + // Ignore per-file errors; we still load the document. + } + } + instance.commands.reset( editor.state.init({ editable: true, @@ -473,6 +489,14 @@ export default function CanvasPlayground({ }), "opfs" ); + + // Load image assets into the WASM runtime. + // This is safe to call before mount; the editor will defer and apply after mount. + const imageCount = Object.keys(images).length; + if (imageCount > 0) { + instance.loadImagesToWasmSurface(images); + } + setDocumentReady(true); return; } @@ -498,7 +522,7 @@ export default function CanvasPlayground({ return () => { cancelled = true; }; - }, [document, instance, src, opfs]); + }, [document, instance, src, opfs, backend]); const ready = documentReady && canvasReady; @@ -627,13 +651,19 @@ function Consumer({ if (opfs) { try { const snapshot = instance.getSnapshot(); - const document = snapshot.document; - const bytes = io.GRID.encode(document); + const dir = instance.archivedir(); + + // Write images into OPFS (images/.) + for (const [filename, bytes] of Object.entries(dir.images)) { + await opfs.writeImage(filename, bytes); + } + + const bytes = io.GRID.encode(dir.document); await opfs.get("document.grida").write(bytes); // Also write document.grida1 for migration purposes const snapshotJson = io.snapshot.stringify({ version: undefined, // Version is optional in snapshot format - document: document, + document: dir.document, }); await opfs .get("document.grida1") diff --git a/editor/grida-canvas/editor.ts b/editor/grida-canvas/editor.ts index 03e880faab..54ec8dc2f3 100644 --- a/editor/grida-canvas/editor.ts +++ b/editor/grida-canvas/editor.ts @@ -710,13 +710,7 @@ class EditorDocumentStore throw new Error("Failed to pack SVG"); } - // Handle both response formats: { success: true, data: { svg } } or direct { svg } - const svgData = - (packed as any).svg || - ((packed as any).success && (packed as any).data?.svg); - if (!svgData) { - throw new Error("Failed to extract SVG data from packed result"); - } + const svgData = packed.svg; let result = await iosvg.convert(svgData, { name: "svg", @@ -1832,12 +1826,14 @@ class EditorDocumentStore const node_ids = Array.isArray(node_id) ? node_id : [node_id]; this.dispatch( node_ids.map((node_id) => { - const current = this.getNodeSnapshotById(node_id); - const currentFills = Array.isArray((current as any).fill_paints) - ? ((current as any).fill_paints as cg.Paint[]) - : (current as any).fill - ? [(current as any).fill as cg.Paint] - : []; + const current = this.getNodeSnapshotById( + node_id + ) as grida.program.nodes.UnknownNode; + const { paints: currentFills } = editor.resolvePaints( + current, + "fill", + 0 + ); const newFills = at === "start" ? [fill, ...currentFills] : [...currentFills, fill]; @@ -1862,11 +1858,11 @@ class EditorDocumentStore const current = this.getNodeSnapshotById(node_id); if (!current) continue; - const currentStrokes = Array.isArray((current as any).stroke_paints) - ? ((current as any).stroke_paints as cg.Paint[]) - : (current as any).stroke - ? [(current as any).stroke as cg.Paint] - : []; + const { paints: currentStrokes } = editor.resolvePaints( + current as grida.program.nodes.UnknownNode, + "stroke", + 0 + ); const newStrokes = at === "start" @@ -2836,6 +2832,42 @@ export class Editor } } + private static __isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); + } + + /** + * Recursively rewrites `{ type: "image", src: string }` paint values in-place. + * + * Returns the number of rewritten `src` fields. + */ + private static __rewriteImagePaintSrcDeep( + value: unknown, + mapSrc: (src: string) => string | undefined + ): number { + if (Array.isArray(value)) { + let sum = 0; + for (const item of value) + sum += Editor.__rewriteImagePaintSrcDeep(item, mapSrc); + return sum; + } + if (!Editor.__isRecord(value)) return 0; + + let rewrites = 0; + if (value["type"] === "image" && typeof value["src"] === "string") { + const mapped = mapSrc(value["src"]); + if (mapped && mapped !== value["src"]) { + value["src"] = mapped; + rewrites++; + } + } + + for (const key of Object.keys(value)) { + rewrites += Editor.__rewriteImagePaintSrcDeep(value[key], mapSrc); + } + return rewrites; + } + public subscribe(fn: editor.api.SubscriptionCallbackFn) { // TODO: we can have a single subscription to the document and use that. // Subscribe to the document store changes @@ -2845,13 +2877,82 @@ export class Editor }); } - public archive(): Blob { - const blob = new Blob( - [io.archive.pack(this.getSnapshot().document) as BlobPart], - { - type: "application/zip", + /** + * Prepares a persisted “archive directory” view of the current document state. + * + * This method is shared by both: + * - ZIP archive export (`.grida` via `io.archive.pack`) + * - OPFS persistence (playground `Cmd/Ctrl+S`) + * + * What it does: + * - Collects **used** image paint `src` references in the document. + * - For the WASM backend, resolves those runtime refs (`mem://...`) to bytes and a + * WASM-provided content hash (hex16), producing `images/.` entries. + * - Rewrites image paint `src` in the persisted document from runtime URL → hash id. + * + * Notes / policy: + * - We intentionally **do not** compute SeaHash in JS; persisted image IDs come from WASM. + * - For DOM backend (hosted HTML flow), we keep external `src` values as-is and do not embed. + * + * TODO: support `thumbnail.png` generation/persistence alongside the document. + */ + public archivedir(): { + document: grida.program.document.Document; + /** + * Image files to persist under `images/`. + * + * Keys are filenames (e.g. ".png"), values are bytes. + */ + images: Record; + } { + const snapshot = this.getSnapshot() + .document as grida.program.document.Document; + const images: Record = {}; + + const usedSrcs = new dq.DocumentStateQuery(snapshot).image_srcs(); + if (this.backend !== "canvas" || !this._m_wasm_canvas_scene) { + throw new Error( + "`archivedir()` requires the canvas/WASM backend to be mounted." + ); + } + + // Collect bytes keyed by hash, by reading them back from WASM. + for (const src of usedSrcs) { + const hashHex = Editor.__try_parse_hex16_from_image_src(src); + if (!hashHex) { + throw new Error( + `Cannot persist image paint src (expected res://images/, mem://, or ): "${src}"` + ); } - ); + + // Rust/WASM normalizes non-RID ids into `res://images/`. + const bytes = this.__get_image_bytes_for_wasm(hashHex); + if (!bytes) { + throw new Error( + `Cannot persist image bytes (WASM missing resource for "${hashHex}")` + ); + } + + const mime = snapshot.images?.[hashHex]?.type; + const ext = (mime && mime.split("/")[1]) || "bin"; + images[`${hashHex}.${ext}`] = bytes; + } + + // Rewrite persisted image paint refs (runtime URL -> hash id) before encoding. + const persisted = structuredClone(snapshot); + Editor.__rewriteImagePaintSrcDeep(persisted, (src) => { + const hex = Editor.__try_parse_hex16_from_image_src(src); + return hex ?? undefined; + }); + + return { document: persisted, images }; + } + + public archive(): Blob { + const { document, images } = this.archivedir(); + const blob = new Blob([io.archive.pack(document, images) as BlobPart], { + type: "application/zip", + }); return blob; } @@ -2909,6 +3010,18 @@ export class Editor ); this._do_legacy_warmup(); + + // If we had pending image assets (e.g. loaded from an archive before mount), + // register them into WASM now. + if (this._pending_image_assets) { + const pending = this._pending_image_assets; + this._pending_image_assets = null; + try { + this.loadImagesToWasmSurface(pending); + } catch (e) { + this.log("failed to load pending image assets into wasm runtime", e); + } + } } /** @@ -3149,6 +3262,79 @@ export class Editor private readonly images = new Map(); + /** + * TODO(resource-lifecycle): replace `loadImagesToWasmSurface` + `_pending_image_assets` + * with a real lifecycle/event hook so hosts can do: + * `editor.once("surface:ready", () => loadAssetsToWasm())` + * instead of relying on a dedicated API and implicit deferral. + * + * (Skip details for now; keep current behavior stable.) + * + * Pending hash-addressed image assets loaded from an archive/OPFS before the WASM + * surface is mounted. Once mounted, we'll register and rewrite refs. + */ + private _pending_image_assets: Record | null = null; + + private static __try_parse_hex16_from_image_src(src: string): string | null { + /** + * TODO(resources): cross-boundary image ids should be `res://...` only. + * + * - `res://...` is the engine/host boundary identifier (returned by WASM today). + * - `mem://...` should remain a rust-native/internal byte-store pointer only. + * + * Once the contract is finalized, drop `mem://` acceptance here and prefer + * parsing `res:///` generically (not hardcoded to `res://images/`). + */ + const raw = src.trim().toLowerCase(); + if (raw.startsWith("res://images/")) { + const hex = raw.slice("res://images/".length); + return /^[0-9a-f]{16}$/.test(hex) ? hex : null; + } + if (raw.startsWith("mem://")) { + const hex = raw.slice("mem://".length); + return /^[0-9a-f]{16}$/.test(hex) ? hex : null; + } + return /^[0-9a-f]{16}$/.test(raw) ? raw : null; + } + + /** + * Loads hash-addressed image bytes into the WASM renderer runtime. + * + * Why this exists: + * - DOM rendering can rely on fetchable URLs (`http(s):`, `blob:`). + * - WASM cannot fetch URLs directly; bytes must be provided by the host. + * The runtime then exposes a stable cross-boundary identifier (`res://...`). + * + * This method intentionally does **NOT** mutate the document (no `src` rewriting), + * so loading assets does not mark the document as "dirty". + * + * TODO(resource-lifecycle): evolve into a dynamic "asset server" flow where the renderer + * requests missing resources on-demand and the editor provides bytes as needed. + */ + public loadImagesToWasmSurface(images: Record) { + if (!images || Object.keys(images).length === 0) return; + + // If we're on canvas backend but not mounted yet, defer. + if (this.backend === "canvas" && !this._m_wasm_canvas_scene) { + this._pending_image_assets = images; + return; + } + + if (this.backend !== "canvas") { + // DOM backend does not require pre-loading bytes into a renderer runtime. + return; + } + + assert(this._m_wasm_canvas_scene, "WASM canvas scene is not initialized"); + for (const [_hash, bytes] of Object.entries(images)) { + try { + this._experimental_createImage_for_wasm(bytes); + } catch (e) { + this.log("failed to load image asset into wasm runtime", e); + } + } + } + __is_image_registered(ref: string): boolean { return this.images.has(ref); } @@ -3191,6 +3377,18 @@ export class Editor }; this.images.set(url, ref); + this.doc.reduce((state) => { + // Persistable image metadata is keyed by canonical content hash (hex16). + // Runtime refs use `res://images/` (returned by WASM) and are stored in `ImagePaint.src` after registration. + state.document.images[hash] = { + url: hash, + width, + height, + bytes: data.byteLength, + type: type as grida.program.document.ImageType, + }; + return state; + }); return ref; } @@ -3262,11 +3460,6 @@ export class Editor type: (type as grida.program.document.ImageType) || "image/png", }; - this.doc.reduce((state) => { - state.document.images[ref.url] = ref; - return state; - }); - return ref; } diff --git a/editor/grida-canvas/query/index.ts b/editor/grida-canvas/query/index.ts index b57517178b..d3e5b55acc 100644 --- a/editor/grida-canvas/query/index.ts +++ b/editor/grida-canvas/query/index.ts @@ -1,6 +1,7 @@ -import type { editor } from ".."; +import { editor } from ".."; import type grida from "@grida/schema"; import assert from "assert"; +import cg from "@grida/cg"; type NodeID = string & {}; @@ -612,5 +613,60 @@ export namespace dq { ) ); } + + /** + * Returns all image paints used across the document (fills + strokes). + * + * This is the right primitive to build "persist resources" steps on top of. + */ + private image_paints(): Array<{ + node_id: string; + target: "fill" | "stroke"; + paint: cg.ImagePaint; + }> { + const out: Array<{ + node_id: string; + target: "fill" | "stroke"; + paint: cg.ImagePaint; + }> = []; + + for (const node_id of this.nodeids) { + const node = this.nodes[node_id]; + if (!node) continue; + + for (const target of ["fill", "stroke"] as const) { + const { paints } = editor.resolvePaints( + node as grida.program.nodes.UnknownNode, + target, + 0 + ); + for (const paint of paints) { + if (cg.isImagePaint(paint)) { + out.push({ node_id, target, paint }); + } + } + } + } + + return out; + } + + /** + * Returns unique image `src` references used by image paints. + * + * - Persisted: typically `hex16` (content hash id) + * - Runtime: typically `blob:` or `mem://...` + */ + image_srcs(): string[] { + return Array.from( + new Set( + this.image_paints() + .map((p) => p.paint.src) + .filter(Boolean) + ) + ); + } } + + // note: paint extraction uses `editor.resolvePaints` for correctness across all node variants } diff --git a/packages/grida-canvas-io/__tests__/format-roundtrip.test.ts b/packages/grida-canvas-io/__tests__/format-roundtrip.test.ts index d899a89993..c82a028ee8 100644 --- a/packages/grida-canvas-io/__tests__/format-roundtrip.test.ts +++ b/packages/grida-canvas-io/__tests__/format-roundtrip.test.ts @@ -845,9 +845,7 @@ describe("format roundtrip", () => { }); describe("cg.BoxFit", () => { - // TODO: Enable when ImageNodeProperties decoding is implemented - // Currently fit is hardcoded to "cover" in decode - it.skip.each([ + it.each([ ["contain", "contain"], ["cover", "cover"], ["fill", "fill"], @@ -869,21 +867,27 @@ describe("format roundtrip", () => { constraints: { children: "multiple" }, }, [nodeId]: { - type: "image", - id: nodeId, - name: "Image", - active: true, - locked: false, - opacity: 1, - z_index: 0, - layout_positioning: "absolute", - layout_inset_left: 0, - layout_inset_top: 0, - layout_target_width: 100, - layout_target_height: 100, - rotation: 0, - fit, - } satisfies grida.program.nodes.ImageNode, + ...baseContainer(nodeId), + fill_paints: [ + { + type: "image", + src: "0123456789abcdef", + fit, + opacity: 1, + blend_mode: "normal", + active: true, + filters: { + exposure: 0, + contrast: 0, + saturation: 0, + temperature: 0, + tint: 0, + highlights: 0, + shadows: 0, + }, + }, + ], + } satisfies grida.program.nodes.ContainerNode, }, links: { [sceneId]: [nodeId] }, scenes_ref: [sceneId], @@ -896,11 +900,15 @@ describe("format roundtrip", () => { const bytes = format.document.encode.toFlatbuffer(doc); const decoded = format.document.decode.fromFlatbuffer(bytes); const node = decoded.nodes[nodeId]; - if (!node || node.type !== "image") - throw new Error("Expected image node"); - node satisfies grida.program.nodes.ImageNode; + if (!node || node.type !== "container") + throw new Error("Expected container node"); + if (!node.fill_paints || node.fill_paints.length === 0) + throw new Error("Expected fill_paints"); + const paint = node.fill_paints[0]; + if (!paint || paint.type !== "image") + throw new Error("Expected image paint"); - expect(node.fit).toBe(expected); + expect(paint.fit).toBe(expected); }); }); @@ -2055,7 +2063,7 @@ describe("format roundtrip", () => { fill_paints: [ { type: "image", - src: "https://example.com/image.png", + src: "0123456789abcdef", fit: "cover", blend_mode: "normal", opacity: 1, @@ -2378,7 +2386,7 @@ describe("format roundtrip", () => { stroke_paints: [ { type: "image", - src: "https://example.com/stroke.png", + src: "fedcba9876543210", fit: "cover", blend_mode: "normal", opacity: 1, diff --git a/packages/grida-canvas-io/format.ts b/packages/grida-canvas-io/format.ts index 1e81b8f355..e001f33523 100644 --- a/packages/grida-canvas-io/format.ts +++ b/packages/grida-canvas-io/format.ts @@ -1655,7 +1655,6 @@ export namespace format { } case "group": case "image": { - // ImageNode is not in the union, encode as GroupNode fbs.GroupNode.startGroupNode(builder); fbs.GroupNode.addNode(builder, systemNodeTraitOffset); fbs.GroupNode.addLayer(builder, layerOffset); @@ -1901,14 +1900,35 @@ export namespace format { builder: Builder, paint: cg.ImagePaint ): { type: fbs.Paint; offset: flatbuffers.Offset } { - // ImagePaint is complex - for now, create a placeholder - // TODO: Implement full ImagePaint encoding (ResourceRef, ImagePaintFit, filters) - // Create ResourceRefRID with src string - const srcOffset = builder.createString(paint.src); - fbs.ResourceRefRID.startResourceRefRID(builder); - fbs.ResourceRefRID.addRid(builder, srcOffset); + // Hash-only persistence for image paints. + // Accepted src forms: + // - "" (canonical persisted identifier) + // - "res://images/" (engine runtime URL) + // - "mem://" (legacy/alternate runtime URL) + // + // NOTE: We intentionally do NOT encode RID/external URLs into FlatBuffers here. + // + // TODO(resources): once `res://` is the only cross-boundary identifier, restrict + // accepted runtime forms here to `res:///` (and drop `mem://` entirely). + // Also, `res://` should map to a general-purpose archive directory path, not a + // hardcoded `images/` folder. + const rawSrc = (paint.src || "").trim().toLowerCase(); + const src = rawSrc.startsWith("res://images/") + ? rawSrc.slice("res://images/".length) + : rawSrc.startsWith("mem://") + ? rawSrc.slice(6) + : rawSrc; + if (!/^[0-9a-f]{16}$/.test(src)) { + throw new Error( + `ImagePaint.src must be hex16 (or res://images/, mem://) for persistence. Got: "${paint.src}"` + ); + } + + const hashOffset = builder.createString(src); + fbs.ResourceRefHASH.startResourceRefHASH(builder); + fbs.ResourceRefHASH.addHash(builder, hashOffset); const resourceRefOffset = - fbs.ResourceRefRID.endResourceRefRID(builder); + fbs.ResourceRefHASH.endResourceRefHASH(builder); // Create ImagePaintFit based on fit type let fitType: fbs.ImagePaintFit; @@ -1949,7 +1969,7 @@ export namespace format { // Structs must be created inline within table context fbs.ImagePaint.startImagePaint(builder); fbs.ImagePaint.addActive(builder, paint.active ?? true); - fbs.ImagePaint.addImageType(builder, fbs.ResourceRef.ResourceRefRID); + fbs.ImagePaint.addImageType(builder, fbs.ResourceRef.ResourceRefHASH); fbs.ImagePaint.addImage(builder, resourceRefOffset); fbs.ImagePaint.addQuarterTurns( builder, @@ -2247,19 +2267,23 @@ export namespace format { * Decodes ImagePaint. */ export function image(paintValue: unknown): cg.ImagePaint { - // ImagePaint decoding is complex - for now return a placeholder - // TODO: Implement full ImagePaint decoding (ResourceRef, ImagePaintFit, filters) + // NOTE: hash-only decode for image resource reference. const imagePaint = paintValue as fbs.ImagePaint; // Decode src from ResourceRef let src = ""; const imageType = imagePaint.imageType(); - if (imageType === fbs.ResourceRef.ResourceRefRID) { + if (imageType === fbs.ResourceRef.ResourceRefHASH) { const resourceRef = imagePaint.image( - new fbs.ResourceRefRID() - ) as fbs.ResourceRefRID | null; + new fbs.ResourceRefHASH() + ) as fbs.ResourceRefHASH | null; if (resourceRef) { - src = resourceRef.rid() ?? ""; + // NOTE: WASM renderer currently uses `res://images/` as the cross-boundary image id. + // Persisted form remains plain hex16; decode returns the runtime identifier. + // + // TODO(resources): once `res:///` is fully generalized, avoid hardcoding `images/`. + const hash = resourceRef.hash() ?? ""; + src = hash ? `res://images/${hash}` : ""; } } diff --git a/packages/grida-canvas-io/index.ts b/packages/grida-canvas-io/index.ts index aa72646821..98508462fb 100644 --- a/packages/grida-canvas-io/index.ts +++ b/packages/grida-canvas-io/index.ts @@ -599,6 +599,14 @@ export namespace io { export interface LoadedDocument { version: typeof grida.program.document.SCHEMA_VERSION; document: grida.program.document.Document; + /** + * Optional raw assets extracted from an archive. + * + * Keys are canonical image hash ids (hex16), values are encoded image bytes. + */ + assets?: { + images: Record; + }; } /** @@ -764,19 +772,25 @@ export namespace io { // Decode FlatBuffers document const document = format.document.decode.fromFlatbuffer(fbBytes); - // Convert images - const images: Record = {}; + // Convert images into a hash-addressed asset map, and populate a minimal images repository + // keyed by hash (persisted identifier). Runtime should resolve/register and rewrite refs. + const imagesRepo: Record = {}; + const assets: Record = {}; + for (const [key, imageData] of Object.entries(_x_images)) { + const base = key.split("/").pop() ?? key; + const hashHex = base.includes(".") ? base.split(".")[0]! : base; + const dimensions = imageSize(new Uint8Array(imageData)); if (!dimensions || !dimensions.width || !dimensions.height) { throw new Error(`Failed to get dimensions for image: ${key}`); } const { width, height, type } = dimensions; const mimeType = IMAGE_TYPE_TO_MIME_TYPE[type || "png"] || "image/png"; - const blob = new Blob([imageData as BlobPart], { type: mimeType }); - const url = URL.createObjectURL(blob); - images[url] = { - url, + + assets[hashHex] = imageData; + imagesRepo[hashHex] = { + url: hashHex, width, height, bytes: imageData.byteLength, @@ -786,7 +800,8 @@ export namespace io { return { version: grida.program.document.SCHEMA_VERSION, - document: { ...document, images, bitmaps: _x_bitmaps }, + document: { ...document, images: imagesRepo, bitmaps: _x_bitmaps }, + assets: { images: assets }, } satisfies LoadedDocument; } @@ -884,6 +899,23 @@ export namespace io { * This is not used for routing; it's informational/diagnostic only. */ version?: string; + /** + * Optional images index for hash-addressed image assets stored under `images/`. + * + * Key is the canonical SeaHash hex16 (lowercase, big-endian) used for filenames. + * Value includes metadata needed to reconstruct `document.images` and/or to + * register resources into WASM. + */ + images?: Record< + string, + { + ext?: string; + mime?: string; + bytes?: number; + width?: number; + height?: number; + } + >; } /** @@ -908,14 +940,15 @@ export namespace io { ): Uint8Array { // Extract bitmaps from document if not provided const inferredBitmaps: Record | undefined = - bitmaps ?? ((document as any).bitmaps as Record); + bitmaps ?? document.bitmaps; // Encode document to FlatBuffers binary const fbBytes = format.document.encode.toFlatbuffer( { - ...(document as any), + ...document, + images: {}, bitmaps: {}, - } as grida.program.document.Document, + }, schemaVersion ); @@ -924,10 +957,10 @@ export namespace io { images: _images, bitmaps: _bitmaps, ...persistedDocument - } = document as any; + } = document; const snapshotJson = io.snapshot.stringify({ version: schemaVersion, - document: persistedDocument as grida.program.document.Document, + document: persistedDocument, }); const manifest: Manifest = { @@ -935,6 +968,20 @@ export namespace io { version: schemaVersion, }; + // Optional image index (best-effort). + if (images && Object.keys(images).length > 0) { + const index: NonNullable = {}; + for (const [key, imageData] of Object.entries(images)) { + const name = key.split("/").pop() ?? key; + const ext = name.includes(".") ? name.split(".").pop() : undefined; + index[name.split(".")[0] ?? name] = { + ext, + bytes: imageData.byteLength, + }; + } + manifest.images = index; + } + const files: Record = { "manifest.json": strToU8(JSON.stringify(manifest)), "document.grida": fbBytes, @@ -945,6 +992,13 @@ export namespace io { // Add images if (images && Object.keys(images).length > 0) { + // TODO(resources): stop hardcoding `images/`. + // + // The long-term contract should treat `res:///` as the cross-boundary identifier, + // and the archive should store files under `/` (directory-local), not only under + // `images/.`. + // + // Today we only persist image assets and only under `images/`. for (const [key, imageData] of Object.entries(images)) { files[`images/${key}`] = imageData; } @@ -1131,9 +1185,10 @@ export namespace io { * Strongly-typed file keys for Grida OPFS structure. */ export type FileKey = - | "document.grida" - | "document.grida1" - | "thumbnail.png"; + // NOTE: images are stored under a directory and addressed by filename. + // We keep these as methods (not union keys) because OPFS directory entries + // are not single files. + "document.grida" | "document.grida1" | "thumbnail.png"; /** * File handle interface for OPFS file operations. @@ -1325,6 +1380,60 @@ export namespace io { } return this._fileHandles.get(key)!; } + + /** + * Writes an image blob into `images/` under this handle directory. + */ + async writeImage(filename: string, bytes: Uint8Array): Promise { + // TODO(resources): stop hardcoding the OPFS `images/` directory. + // Once `res:///` is the cross-boundary identifier, OPFS should store + // assets under `/...` accordingly (general-purpose directory-local layout). + const dir = await this.getDirectoryHandle(); + const imagesDir = await dir.getDirectoryHandle("images", { + create: true, + }); + const fileHandle = await imagesDir.getFileHandle(filename, { + create: true, + }); + const writable = await fileHandle.createWritable(); + await writable.write(bytes as unknown as FileSystemWriteChunkType); + await writable.close(); + } + + /** + * Lists image filenames under `images/`. + */ + async listImages(): Promise { + const dir = await this.getDirectoryHandle(); + try { + const imagesDir = await dir.getDirectoryHandle("images"); + const names: string[] = []; + // NOTE: TypeScript's lib.dom typing for the File System Access API varies by TS/lib config. + // We avoid `as any`, but still need a narrow typing bridge for `entries()` in some configs. + const entries = ( + imagesDir as unknown as { + entries(): AsyncIterableIterator<[string, FileSystemHandle]>; + } + ).entries(); + for await (const [name, handle] of entries) { + if (handle && handle.kind === "file") names.push(name); + } + return names; + } catch { + return []; + } + } + + /** + * Reads `images/` bytes. + */ + async readImage(filename: string): Promise { + const dir = await this.getDirectoryHandle(); + const imagesDir = await dir.getDirectoryHandle("images"); + const fileHandle = await imagesDir.getFileHandle(filename); + const file = await fileHandle.getFile(); + return new Uint8Array(await file.arrayBuffer()); + } } }