diff --git a/plugins/hyperimage/package.json b/plugins/hyperimage/package.json index 420a5a3..4918f8a 100644 --- a/plugins/hyperimage/package.json +++ b/plugins/hyperimage/package.json @@ -9,6 +9,10 @@ "types": "./dist/Node.d.ts", "default": "./dist/Node.js" }, + "./storage": { + "types": "./dist/storage/index.d.ts", + "default": "./dist/storage/index.js" + }, "./css": { "default": "./dist/hyperimage.css" } @@ -21,9 +25,11 @@ ], "scripts": { "build": "tsdown", - "dev": "tsdown --watch --ignore-watch .turbo --ignore-watch dist" + "dev": "tsdown --watch --ignore-watch .turbo --ignore-watch dist", + "test": "vitest run" }, "devDependencies": { + "fake-indexeddb": "^6.0.0", "@fujocoded/astrolabe-editor-tree-viewer": "workspace:*", "@storybook/addon-vitest": "catalog:storybook", "@tiptap/react": "catalog:", diff --git a/plugins/hyperimage/src/Node.tsx b/plugins/hyperimage/src/Node.tsx index 6533e4b..cf734c9 100644 --- a/plugins/hyperimage/src/Node.tsx +++ b/plugins/hyperimage/src/Node.tsx @@ -4,8 +4,15 @@ import { type ImageOptions, } from "@tiptap/extension-image"; import { PasteDropHandler } from "./PasteDropHandler"; +import { + defaultStore, + generateSessionId, + removeOrphanedImages, + type ProcessorConfig, +} from "./storage"; import "./hyperimage.css"; -import type { HtmlHTMLAttributes } from "react"; + +const IMAGES_HEARTBEAT_MS = 5 * 60 * 1000; // TODO: fix once tiptap fixes issues with types https://github.com/ueberdosis/tiptap/issues/6670 type RenderHTMLType = { @@ -19,11 +26,46 @@ type RenderHTMLType = { export type HyperimageOptions = ImageOptions & { HTMLAttributes: Partial<{ "data-astrolb-type": string; - // TODO: a secret tool that will help us later "data-astrolb-id": string; }>; + imageOptions?: Partial>; + /** + * Document ID for scoped storage cleanup. + * + * If provided, orphaned images for this document are cleaned up on editor init. + * If not provided, a session ID is auto-generated and images can be cleaned up + * according to age. + */ + documentId?: string; }; +function setUpStorage({ + editor, + scopeId, + trackedIds, + activeImageIds, +}: { + editor: Editor; + scopeId: string; + trackedIds: Set; + activeImageIds: string[]; +}) { + removeOrphanedImages(defaultStore, scopeId, activeImageIds).catch((err) => + console.warn("Failed to reconcile storage:", err), + ); + + const heartbeat = setInterval(() => { + const ids = [...trackedIds]; + if (ids.length > 0) { + defaultStore + .refreshLastUsed(ids) + .catch((err) => console.warn("Failed to touch images:", err)); + } + }, IMAGES_HEARTBEAT_MS); + + editor.on("destroy", () => clearInterval(heartbeat)); +} + export const Plugin = ImageExtension.extend({ name: "hyperimage", @@ -85,7 +127,70 @@ export const Plugin = ImageExtension.extend({ ]; }, + addStorage() { + return { + trackedIds: new Set(), + scopeId: generateSessionId(), + }; + }, + + onCreate() { + if (this.options.documentId) { + this.storage.scopeId = this.options.documentId; + } + + const activeImageIds: string[] = []; + this.editor.state.doc.descendants((node) => { + if (node.type.name === this.name && node.attrs.id) { + activeImageIds.push(node.attrs.id); + this.storage.trackedIds.add(node.attrs.id); + } + }); + + const editor = this.editor.options.element; + if (editor instanceof HTMLElement) { + editor.setAttribute("data-astrolb-scope-id", this.storage.scopeId); + } + + setUpStorage({ + editor: this.editor, + scopeId: this.storage.scopeId, + trackedIds: this.storage.trackedIds, + activeImageIds, + }); + }, + + onTransaction({ transaction }) { + if (!transaction.docChanged) return; + + const currentIds = new Set(); + transaction.doc.descendants((node) => { + if (node.type.name === this.name && node.attrs.id) { + currentIds.add(node.attrs.id); + } + }); + + const deletedIds = [...this.storage.trackedIds].filter( + (id) => !currentIds.has(id), + ); + + if (deletedIds.length > 0) { + defaultStore + .deleteMany(deletedIds) + .catch((err) => console.warn("Failed to delete images:", err)); + } + + this.storage.trackedIds = currentIds; + }, + addProseMirrorPlugins() { - return [PasteDropHandler(this.editor)]; + return [ + PasteDropHandler(this.editor, { + processorConfig: { + ...this.options.imageOptions, + scopeId: this.storage.scopeId, + }, + }), + ]; }, }); diff --git a/plugins/hyperimage/src/PasteDropHandler.tsx b/plugins/hyperimage/src/PasteDropHandler.tsx index e693166..56f5573 100644 --- a/plugins/hyperimage/src/PasteDropHandler.tsx +++ b/plugins/hyperimage/src/PasteDropHandler.tsx @@ -1,59 +1,61 @@ import { type Editor } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { + processImageForEditor, + defaultStore, + type ProcessorConfig, +} from "./storage"; -const fileToBase64 = (file: File): Promise => { - const { promise, resolve, reject } = Promise.withResolvers(); - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)); - reader.readAsDataURL(file); - return promise; -}; - -const imageToFile = async (imageUrl: string): Promise => { +async function imageUrlToFile(imageUrl: string): Promise { const response = await fetch(imageUrl); const blob = await response.blob(); const filename = new URL(imageUrl).pathname.split("/").pop() || "image"; return new File([blob], filename, { type: blob.type }); -}; +} -const isAllowedMimeType = (mimeType: string): boolean => { +function isAllowedMimeType(mimeType: string): boolean { return mimeType.startsWith("image/"); -}; +} -const insertImage = async ({ +async function insertImage({ editor, file, + processorConfig, }: { editor: Editor; file: File; -}): Promise => { - const imageId = crypto.randomUUID(); - const base64Data = await fileToBase64(file); + processorConfig?: Partial; +}): Promise { + const processed = await processImageForEditor( + file, + defaultStore, + processorConfig, + ); editor .chain() .insertContent({ type: "hyperimage", attrs: { - src: base64Data, - id: imageId, + src: processed.displaySrc, + ...(processed.wasStored && { id: processed.id }), }, }) .focus() .scrollIntoView() .run(); -}; - -/** - * ProseMirror plugin that handles pasting and dropping images - * Converts images to base64 and inserts them as hyperimage nodes - * - * Based on tiptap's file handler plugin: - * https://github.com/ueberdosis/tiptap/blob/develop/packages/extension-file-handler/src/FileHandlePlugin.ts - * https://tiptap.dev/docs/editor/extensions/functionality/filehandler - */ -export const PasteDropHandler = (editor: Editor) => { +} + +export interface PasteDropHandlerOptions { + processorConfig?: Partial; +} + +export function PasteDropHandler( + editor: Editor, + options: PasteDropHandlerOptions = {}, +) { + const { processorConfig } = options; + return new Plugin({ key: new PluginKey("hyperimage-pasteAndDrop"), @@ -72,7 +74,9 @@ export const PasteDropHandler = (editor: Editor) => { event.preventDefault(); event.stopPropagation(); - imageFiles.forEach((file) => insertImage({ editor, file })); + imageFiles.forEach((file) => + insertImage({ editor, file, processorConfig }), + ); return true; }, @@ -93,28 +97,27 @@ export const PasteDropHandler = (editor: Editor) => { // gifs or webms as they are not copied correctly when moved as files // and will end up transformed into a PNG. This way, we can instead // keep the original image type and data. - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlContent, "text/html"); - const images = doc.querySelectorAll("img"); - - // We're specifically "firing and forgetting": rather than block on the - // image being loaded, we will just add it once it is. - // TODO: this will case problems if multiple images load at different times, and - // potentially if the user changes their current position. - images.forEach(async (image) => { - const file = await imageToFile(image.src); - insertImage({ editor, file }); + const parsedDoc = new DOMParser().parseFromString( + htmlContent, + "text/html", + ); + // TODO: this may cause ordering issues with multiple images but it's + // good enough for now + parsedDoc.querySelectorAll("img").forEach(async (image) => { + const file = await imageUrlToFile(image.src); + insertImage({ editor, file, processorConfig }); }); return true; } - // There was no html content, so we can insert the images directly from the file data event.preventDefault(); event.stopPropagation(); - imageFiles.forEach((file) => insertImage({ editor, file })); + imageFiles.forEach((file) => + insertImage({ editor, file, processorConfig }), + ); return true; }, }, }); -}; +} diff --git a/plugins/hyperimage/src/image-processor.ts b/plugins/hyperimage/src/image-processor.ts new file mode 100644 index 0000000..18ceef8 --- /dev/null +++ b/plugins/hyperimage/src/image-processor.ts @@ -0,0 +1,175 @@ +import { blobToDataURL, getImageDimensions } from "./image-utils"; +import { + type ImageStore, + type ImageMetadata, + generateImageId, +} from "./image-store"; + +export type StoragePolicy = "always" | "when-resized" | "never"; + +export interface ProcessorConfig { + maxWidth: number; + maxSizeBytes: number; + quality: number; + storagePolicy: StoragePolicy; + scopeId?: string; +} + +export const DEFAULT_PROCESSOR_CONFIG: Omit = { + maxWidth: 800, + maxSizeBytes: 500 * 1024, // 500KB + quality: 0.85, + storagePolicy: "when-resized", +}; + +export interface ProcessedImage { + id: string; + displaySrc: string; + originalWidth: number; + originalHeight: number; + wasResized: boolean; + wasStored: boolean; +} + +function loadBlobAsImage(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(blob); + + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Failed to load image")); + }; + + img.src = url; + }); +} + +async function resizeBlob( + blob: Blob, + maxWidth: number, + quality: number, +): Promise { + const { width, height } = await getImageDimensions(blob); + + if (width <= maxWidth) { + return blob; + } + + const aspectRatio = height / width; + const newHeight = Math.round(maxWidth * aspectRatio); + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Failed to get canvas context"); + } + + const img = await loadBlobAsImage(blob); + + canvas.width = maxWidth; + canvas.height = newHeight; + ctx.drawImage(img, 0, 0, maxWidth, newHeight); + + return new Promise((resolve, reject) => { + canvas.toBlob( + (resizedBlob) => { + if (resizedBlob) { + resolve(resizedBlob); + } else { + reject(new Error("Failed to create resized blob")); + } + }, + "image/jpeg", + quality, + ); + }); +} + +function shouldStore( + policy: StoragePolicy, + wasResized: boolean, + hasStore: boolean, +): boolean { + if (!hasStore) return false; + + switch (policy) { + case "always": + return true; + case "when-resized": + return wasResized; + case "never": + return false; + } +} + +export async function processImageForEditor( + file: File | Blob, + store?: ImageStore, + config: Partial = {}, +): Promise { + const { maxWidth, maxSizeBytes, quality, storagePolicy, scopeId } = { + ...DEFAULT_PROCESSOR_CONFIG, + ...config, + }; + + const imageId = generateImageId(); + const { width: originalWidth, height: originalHeight } = + await getImageDimensions(file); + + const needsResize = file.size > maxSizeBytes || originalWidth > maxWidth; + const displayBlob = needsResize + ? await resizeBlob(file, maxWidth, quality).catch((error) => { + console.warn("Resize failed, using original:", error); + return file; + }) + : file; + const wasResized = displayBlob !== file; + + const metadata: ImageMetadata = { + width: originalWidth, + height: originalHeight, + mimeType: file.type, + fileSize: file.size, + ...(file instanceof File && { fileName: file.name }), + }; + + const wasStored = shouldStore(storagePolicy, wasResized, !!store); + if (wasStored && store) { + await store.store(imageId, file, metadata, scopeId); + } + + const displaySrc = await blobToDataURL(displayBlob); + + return { + id: imageId, + displaySrc, + originalWidth, + originalHeight, + wasResized, + wasStored, + }; +} + +export function createImageProcessor( + store?: ImageStore, + defaultConfig: Partial = {}, +) { + return { + process( + file: File | Blob, + config?: Partial, + ): Promise { + return processImageForEditor(file, store, { + ...defaultConfig, + ...config, + }); + }, + }; +} + diff --git a/plugins/hyperimage/src/image-store.ts b/plugins/hyperimage/src/image-store.ts new file mode 100644 index 0000000..f746907 --- /dev/null +++ b/plugins/hyperimage/src/image-store.ts @@ -0,0 +1,70 @@ +export interface ImageMetadata { + width: number; + height: number; + fileName?: string; + fileSize?: number; + mimeType?: string; +} + +export interface StoredImage { + id: string; + originalBlob: Blob; + metadata: ImageMetadata; + timestamp: number; + lastUsed: number; + scopeId?: string; +} + +export interface ImageStore { + store( + id: string, + blob: Blob, + metadata: ImageMetadata, + scopeId?: string, + ): Promise; + get(id: string): Promise; + delete(id: string): Promise; + deleteMany(ids: string[]): Promise; + listIds(): Promise; + clear(): Promise; + listByScope(scopeId: string): Promise; + deleteByScope(scopeId: string): Promise; + refreshLastUsed(ids: string[]): Promise; + deleteOlderThan(maxAgeMs: number): Promise<{ deleted: number }>; +} + +export function generateImageId(): string { + return crypto.randomUUID(); +} + +export function generateSessionId(): string { + return `session-${crypto.randomUUID()}`; +} + +export interface ReconcileResult { + deleted: string[]; + active: string[]; +} + +export async function removeOrphanedImages( + store: ImageStore, + scopeId: string, + activeImageIds: string[], +): Promise { + const storedIds = await store.listByScope(scopeId); + const activeSet = new Set(activeImageIds); + + const orphanIds = storedIds.filter((id) => !activeSet.has(id)); + + if (orphanIds.length > 0) { + await store.deleteMany(orphanIds); + console.info( + `Reconciled scope "${scopeId}": deleted ${orphanIds.length} orphans`, + ); + } + + return { + deleted: orphanIds, + active: storedIds.filter((id) => activeSet.has(id)), + }; +} diff --git a/plugins/hyperimage/src/image-utils.ts b/plugins/hyperimage/src/image-utils.ts new file mode 100644 index 0000000..4fc7adc --- /dev/null +++ b/plugins/hyperimage/src/image-utils.ts @@ -0,0 +1,50 @@ +export function blobToDataURL(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("Failed to read blob")); + reader.readAsDataURL(blob); + }); +} + +export function dataURLToBlob(dataURL: string): Blob { + const [header, base64] = dataURL.split(","); + const mimeMatch = header.match(/data:([^;]+)/); + const mimeType = mimeMatch?.[1] ?? "application/octet-stream"; + + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return new Blob([bytes], { type: mimeType }); +} + +export function createBlobURL(blob: Blob): string { + return URL.createObjectURL(blob); +} + +export function getImageDimensions( + blob: Blob +): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const img = new Image(); + // Create a temporary URL to load the blob into an element, + // which is the only way to read its native dimensions. + const url = URL.createObjectURL(blob); + + img.onload = () => { + // Release the object URL to avoid memory leaks + URL.revokeObjectURL(url); + resolve({ width: img.width, height: img.height }); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Failed to load image")); + }; + + img.src = url; + }); +} diff --git a/plugins/hyperimage/src/indexed-db-image-store.ts b/plugins/hyperimage/src/indexed-db-image-store.ts new file mode 100644 index 0000000..df600fb --- /dev/null +++ b/plugins/hyperimage/src/indexed-db-image-store.ts @@ -0,0 +1,228 @@ +import type { ImageStore, ImageMetadata, StoredImage } from "./image-store"; + +const DB_NAME = "AstrolabeImageStorage"; +const DB_VERSION = 1; +const STORE_NAME = "images"; + +export class IndexedDBImageStore implements ImageStore { + private db: IDBDatabase | null = null; + + private async ensureDbOpen(): Promise { + // Re-open if connection was lost or invalidated (e.g. by a version change in another tab) + if (this.db && this.db.objectStoreNames.contains(STORE_NAME)) { + return this.db; + } + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + + request.onsuccess = () => { + this.db = request.result; + + this.db.onclose = () => { + console.warn("Database connection closed"); + this.db = null; + }; + + this.db.onerror = (event) => { + console.error("Database error:", event); + }; + + resolve(this.db); + }; + + request.onupgradeneeded = (event) => { + const database = (event.target as IDBOpenDBRequest).result; + + const store = database.createObjectStore(STORE_NAME, { + keyPath: "id", + }); + store.createIndex("timestamp", "timestamp", { unique: false }); + store.createIndex("scopeId", "scopeId", { unique: false }); + store.createIndex("lastUsed", "lastUsed", { unique: false }); + }; + }); + } + + async store( + id: string, + blob: Blob, + metadata: ImageMetadata, + scopeId?: string, + ): Promise { + const database = await this.ensureDbOpen(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + + const now = Date.now(); + const imageData: StoredImage = { + id, + originalBlob: blob, + metadata, + timestamp: now, + lastUsed: now, + scopeId, + }; + + const request = store.put(imageData); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async get(id: string): Promise { + const database = await this.ensureDbOpen(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readonly"); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(id); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result ?? null); + }); + } + + async delete(id: string): Promise { + const database = await this.ensureDbOpen(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(id); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async deleteMany(ids: string[]): Promise { + if (ids.length === 0) return; + + const database = await this.ensureDbOpen(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + + for (const id of ids) { + store.delete(id); + } + }); + } + + async listIds(): Promise { + const database = await this.ensureDbOpen(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readonly"); + const store = transaction.objectStore(STORE_NAME); + const request = store.getAllKeys(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result as string[]); + }); + } + + async clear(): Promise { + const database = await this.ensureDbOpen(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.clear(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async listByScope(scopeId: string): Promise { + const database = await this.ensureDbOpen(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readonly"); + const store = transaction.objectStore(STORE_NAME); + const index = store.index("scopeId"); + const request = index.getAllKeys(IDBKeyRange.only(scopeId)); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result as string[]); + }); + } + + async deleteByScope(scopeId: string): Promise { + const ids = await this.listByScope(scopeId); + await this.deleteMany(ids); + } + + async refreshLastUsed(ids: string[]): Promise { + if (ids.length === 0) return; + + const database = await this.ensureDbOpen(); + const now = Date.now(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + + for (const id of ids) { + const getReq = store.get(id); + getReq.onsuccess = () => { + const record = getReq.result; + if (record) { + record.lastUsed = now; + store.put(record); + } + }; + } + }); + } + + async deleteOlderThan(maxAgeMs: number): Promise<{ deleted: number }> { + const database = await this.ensureDbOpen(); + const cutoff = Date.now() - maxAgeMs; + + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const index = store.index("lastUsed"); + const range = IDBKeyRange.upperBound(cutoff); + + const idsToDelete: string[] = []; + + const cursorRequest = index.openCursor(range); + + cursorRequest.onerror = () => reject(cursorRequest.error); + + cursorRequest.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result; + if (cursor) { + idsToDelete.push(cursor.value.id); + cursor.continue(); + } else { + for (const id of idsToDelete) { + store.delete(id); + } + } + }; + + transaction.oncomplete = () => resolve({ deleted: idsToDelete.length }); + transaction.onerror = () => reject(transaction.error); + }); + } +} + +export function createIndexedDBStore(): ImageStore { + return new IndexedDBImageStore(); +} diff --git a/plugins/hyperimage/src/storage/index.ts b/plugins/hyperimage/src/storage/index.ts new file mode 100644 index 0000000..b998f77 --- /dev/null +++ b/plugins/hyperimage/src/storage/index.ts @@ -0,0 +1,33 @@ +export { + blobToDataURL, + dataURLToBlob, + createBlobURL, + getImageDimensions, +} from "../image-utils"; + +export { + type ImageStore, + type StoredImage, + type ImageMetadata, + generateImageId, + generateSessionId, + removeOrphanedImages, + type ReconcileResult, +} from "../image-store"; + +export { + IndexedDBImageStore, + createIndexedDBStore, +} from "../indexed-db-image-store"; + +export { + processImageForEditor, + createImageProcessor, + DEFAULT_PROCESSOR_CONFIG, + type ProcessorConfig, + type ProcessedImage, + type StoragePolicy, +} from "../image-processor"; + +import { createIndexedDBStore } from "../indexed-db-image-store"; +export const defaultStore = createIndexedDBStore(); diff --git a/plugins/hyperimage/stories/HyperImage.stories.tsx b/plugins/hyperimage/stories/HyperImage.stories.tsx index 06d93f7..b103193 100644 --- a/plugins/hyperimage/stories/HyperImage.stories.tsx +++ b/plugins/hyperimage/stories/HyperImage.stories.tsx @@ -8,14 +8,16 @@ import { withEditorTreeViewer, type EditorTreeViewConfig, } from "@fujocoded/astrolabe-editor-tree-viewer/decorator"; +import { defaultStore, blobToDataURL } from "../src/storage"; +import { withStorageDebugPanel } from "./StorageDebugPanel"; import Robbie from "./assets/robbie.small.png"; import Sportacus from "./assets/sportacus.small.png"; -const urlToBlob = async (url: string): Promise => { +async function urlToBlob(url: string): Promise { const response = await fetch(url); return response.blob(); -}; +} const editorTreeViews: EditorTreeViewConfig[] = [ { @@ -28,6 +30,30 @@ const editorTreeViews: EditorTreeViewConfig[] = [ }; }, }, + { + id: "stored-images", + label: "Stored Originals", + compute: async () => { + const ids = await defaultStore.listIds(); + const images: Record = {}; + + for (const id of ids) { + const stored = await defaultStore.get(id); + if (stored) { + const dataUrl = await blobToDataURL(stored.originalBlob); + images[id] = { + metadata: stored.metadata, + preview: dataUrl.slice(0, 100) + "...", + }; + } + } + + return { + type: "json", + content: { storedCount: ids.length, images } as Record, + }; + }, + }, ]; const meta = { @@ -94,11 +120,11 @@ export const PasteImage: Story = { dataTransfer.items.add( new File([await urlToBlob(Robbie)], "robbie.small.png", { type: "image/png", - }) + }), ); const editor = canvasElement.querySelector( - ".astrolabe-editor p:last-of-type" + ".astrolabe-editor p:last-of-type", ); await userEvent.click(editor); await userEvent.keyboard("About to paste image..."); @@ -106,3 +132,34 @@ export const PasteImage: Story = { await userEvent.paste(dataTransfer); }, }; + +export const PasteWithResize: Story = { + args: { + initialText: `

Paste an image - it will be resized to 100px width for display, but the original is preserved!

`, + plugins: [ + HyperImage.configure({ + imageOptions: { maxWidth: 100, maxSizeBytes: 0 }, + }), + ], + }, + parameters: { + storyPlacement: "after", + }, + decorators: [withStorageDebugPanel], + play: async ({ canvasElement }) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File([await urlToBlob(Robbie)], "robbie.small.png", { + type: "image/png", + }), + ); + + const editor = canvasElement.querySelector( + ".astrolabe-editor p:last-of-type", + ); + await userEvent.click(editor); + await userEvent.keyboard("Pasting resized image..."); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.paste(dataTransfer); + }, +}; diff --git a/plugins/hyperimage/stories/StorageDebugPanel.tsx b/plugins/hyperimage/stories/StorageDebugPanel.tsx new file mode 100644 index 0000000..e0e2433 --- /dev/null +++ b/plugins/hyperimage/stories/StorageDebugPanel.tsx @@ -0,0 +1,350 @@ +import { + useState, + useEffect, + useRef, + useCallback, + type ComponentType, +} from "react"; +import { + defaultStore, + blobToDataURL, + type ImageMetadata, +} from "../src/storage"; + +const TWENTY_MINUTES = 20 * 60 * 1000; +const REFRESH_INTERVAL = 10_000; + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + +function formatTime(ts: number): string { + return new Date(ts).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +interface ScopeImage { + dataUrl: string; + metadata: ImageMetadata; + timestamp: number; + lastUsed: number; +} + +interface ScopeData { + scopeId: string; + images: ScopeImage[]; +} + +interface LogEntry { + time: number; + message: string; +} + +function ScopeCard({ + label, + images, + isCurrent, +}: { + label: string; + images?: ScopeImage[]; + isCurrent: boolean; +}) { + const hasImages = images && images.length > 0; + const oldestLastUsed = hasImages + ? Math.min(...images.map((i) => i.lastUsed)) + : 0; + const now = Date.now(); + const age = now - oldestLastUsed; + const ttl = TWENTY_MINUTES - age; + + return ( +
+

{label}

+ {hasImages ? ( + <> +

+ {formatDuration(age)} ago + {!isCurrent && ` · last used ${formatTime(oldestLastUsed)}`} + {!isCurrent && ttl > 0 && ` · cleanup in ${formatDuration(ttl)}`} + {!isCurrent && ttl <= 0 && " · due for cleanup"} +

+ {images.map(({ dataUrl, metadata }, i) => ( +
+

+ {metadata.width}x{metadata.height} +

+ +
+ ))} + + ) : ( +

No images yet

+ )} +
+ ); +} + +function EventLog({ entries }: { entries: LogEntry[] }) { + const endRef = useRef(null); + + return ( +
+ {entries.length === 0 ? ( + No events yet + ) : ( + entries.map((e, i) => ( +
+ {formatTime(e.time)}{" "} + {e.message} +
+ )) + )} +
+
+ ); +} + +export function StorageDebugPanel({ + containerRef, +}: { + containerRef: React.RefObject; +}) { + const [scopes, setScopes] = useState([]); + const [log, setLog] = useState([]); + const [currentScopeId, setCurrentScopeId] = useState(); + const knownIdsRef = useRef>(new Set()); + + const addLog = useCallback((message: string) => { + setLog((prev) => [...prev, { time: Date.now(), message }]); + }, []); + + const refresh = useCallback(async () => { + const ids = await defaultStore.listIds(); + const currentIds = new Set(ids); + + const known = knownIdsRef.current; + const added = ids.filter((id) => !known.has(id)); + const removed = [...known].filter((id) => !currentIds.has(id)); + if (added.length > 0) addLog(`+${added.length} image(s) stored`); + if (removed.length > 0) addLog(`-${removed.length} image(s) removed`); + knownIdsRef.current = currentIds; + + const byScope = new Map(); + for (const id of ids) { + const stored = await defaultStore.get(id); + if (stored) { + const scope = stored.scopeId ?? "unknown"; + const dataUrl = await blobToDataURL(stored.originalBlob); + if (!byScope.has(scope)) byScope.set(scope, []); + byScope.get(scope)!.push({ + dataUrl, + metadata: stored.metadata, + timestamp: stored.timestamp, + lastUsed: stored.lastUsed, + }); + } + } + + const sorted = [...byScope.entries()] + .sort( + ([, a], [, b]) => + Math.max(...b.map((i) => i.timestamp)) - + Math.max(...a.map((i) => i.timestamp)), + ) + .map(([scopeId, images]) => ({ + scopeId, + images: images.sort((a, b) => a.timestamp - b.timestamp), + })); + + setScopes(sorted); + return sorted; + }, [addLog]); + + useEffect(() => { + const check = () => { + const id = + document + .querySelector("[data-astrolb-scope-id]") + ?.getAttribute("data-astrolb-scope-id") ?? undefined; + if (id) setCurrentScopeId(id); + }; + check(); + const timeout = setTimeout(check, 500); + return () => clearTimeout(timeout); + }, []); + + useEffect(() => { + let cancelled = false; + + (async () => { + const pruned = await defaultStore.deleteOlderThan(TWENTY_MINUTES); + if (cancelled) return; + if (pruned.deleted > 0) { + addLog(`Pruned ${pruned.deleted} stale image(s)`); + } + + knownIdsRef.current = new Set(await defaultStore.listIds()); + if (cancelled) return; + + const result = await refresh(); + if (cancelled) return; + + const imageCount = result.reduce((n, s) => n + s.images.length, 0); + if (imageCount > 0) { + addLog( + `Init: ${imageCount} image(s) across ${result.length} session(s)`, + ); + } else { + addLog("Init: store is empty"); + } + })(); + + return () => { + cancelled = true; + }; + }, [refresh, addLog]); + + useEffect(() => { + const interval = setInterval(refresh, REFRESH_INTERVAL); + + // Refresh shortly after paste/drop so the store write has time to complete. + const container = containerRef.current; + const delayedRefresh = () => setTimeout(refresh, 500); + container?.addEventListener("paste", delayedRefresh); + container?.addEventListener("drop", delayedRefresh); + + return () => { + clearInterval(interval); + container?.removeEventListener("paste", delayedRefresh); + container?.removeEventListener("drop", delayedRefresh); + }; + }, [refresh, containerRef]); + + const currentScope = scopes.find((s) => s.scopeId === currentScopeId); + const oldScopes = scopes.filter((s) => s.scopeId !== currentScopeId); + const now = Date.now(); + const expiredCount = scopes.reduce( + (n, s) => + n + s.images.filter((i) => now - i.lastUsed >= TWENTY_MINUTES).length, + 0, + ); + + return ( +
+ + + +
+ + +
+

Event log

+ +
+
+ + {oldScopes.length > 0 && ( +
+

Old sessions

+ {oldScopes.map((s) => ( + + ))} +
+ )} +
+ ); +} + +function StorageDebugWrapper({ Story }: { Story: ComponentType }) { + const containerRef = useRef(null); + return ( +
+ + +
+ ); +} + +export function withStorageDebugPanel(Story: ComponentType) { + return ; +} diff --git a/plugins/hyperimage/tsdown.config.ts b/plugins/hyperimage/tsdown.config.ts index b6c495e..c1ddc7a 100644 --- a/plugins/hyperimage/tsdown.config.ts +++ b/plugins/hyperimage/tsdown.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "tsdown"; export default defineConfig([ { name: "Node", - entry: ["./src/Node.tsx"], + entry: ["./src/Node.tsx", "./src/storage/index.ts"], dts: true, clean: true, unbundle: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bf7e60..a38419d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -337,6 +337,9 @@ importers: astrolabe-test-utils: specifier: workspace:* version: link:../../addons/test-utils + fake-indexeddb: + specifier: ^6.0.0 + version: 6.2.5 react: specifier: catalog:react version: 19.1.1 @@ -1796,6 +1799,10 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-equals@5.3.3: resolution: {integrity: sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==} engines: {node: '>=6.0.0'} @@ -2683,6 +2690,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -4048,6 +4056,8 @@ snapshots: expect-type@1.2.2: {} + fake-indexeddb@6.2.5: {} + fast-equals@5.3.3: {} fdir@6.5.0(picomatch@4.0.3):