/// /** * Smart Text Input - Multi-modal text input pattern * * Provides three input modes: * 1. Direct text input (textarea) * 2. Text file upload (.txt, .md, .csv, etc.) * 3. Image upload with OCR (Claude vision API) * * Design decisions: * - Unified UI: textarea with file/image buttons below (not tabs) * - Manual commit: preview before replacing text * - Single image: simpler flow, avoids concatenation ambiguity * - Minimal API: expose only value Cell, manage internal state internally * - LLM-based OCR: uses Claude vision, already integrated * * Usage: * const smartInput = SmartTextInput({ $value: inputText }); * // Use smartInput.ui in your pattern's UI * * Or compose individual parts: * {smartInput.ui.textArea} * {smartInput.ui.buttons} * {smartInput.ui.preview} */ import { Cell, computed, generateText, handler, ifElse, type ImageData, } from "commontools"; // ===== Types ===== // FileData matches ct-file-input's event shape (not exported from commontools) interface FileData { id: string; name: string; url: string; data: string; timestamp: number; size: number; type: string; } // Constants for file handling const DEFAULT_MAX_TEXT_FILE_SIZE = 1 * 1024 * 1024; // 1MB limit for text files export interface SmartTextInputInput { // Required: Target text cell (bidirectional binding) // Accepts both Cell and pattern input types (OpaqueCell) // deno-lint-ignore no-explicit-any $value: any; // Cell | OpaqueCell - framework handles type coercion // Optional configuration placeholder?: string; rows?: number; maxImageSizeBytes?: number; // Default: 3.75MB (75% of 5MB API limit) maxTextFileSizeBytes?: number; // Default: 1MB } export interface SmartTextInputOutput { // State value: Cell; pending: boolean; error: string | null; // Pre-composed UI components ui: { complete: JSX.Element; textArea: JSX.Element; buttons: JSX.Element; preview: JSX.Element; }; } // ===== Constants ===== const DEFAULT_PLACEHOLDER = "Paste text, upload a file, or snap a photo of a business card..."; const DEFAULT_MAX_IMAGE_SIZE = 3.75 * 1024 * 1024; // 3.75MB (75% of 5MB limit) const OCR_SYSTEM_PROMPT = `You are an OCR system. Extract all text from the provided image. Return ONLY the extracted text, preserving formatting and line breaks. Do not add any commentary, explanation, or formatting like markdown. If no text is visible, return an empty string.`; // ===== Handlers (defined OUTSIDE pattern function) ===== /** * Decode base64 data URL to UTF-8 text * Uses TextDecoder for proper multi-byte character handling */ function decodeBase64ToText(dataUrl: string): string { const base64Match = dataUrl.match(/base64,(.+)/); if (!base64Match) { throw new Error("Invalid data URL format"); } // Use TextDecoder for proper UTF-8 handling (same as uri-utils.ts) const binaryString = atob(base64Match[1]); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } const decoder = new TextDecoder(); return decoder.decode(bytes); } /** * Handle text file upload - reads file content and sets as preview */ const handleFileUpload = handler< { detail: { files: FileData[] } }, { previewText: Cell; previewSource: Cell<"file" | "image" | null>; previewFileName: Cell; fileError: Cell; uploadedImage: Cell; maxTextFileSizeBytes: number; } >( ( { detail }, { previewText, previewSource, previewFileName, fileError, uploadedImage, maxTextFileSizeBytes, }, ) => { // Clear any previous error fileError.set(null); const files = detail?.files; if (!files || files.length === 0) return; const file = files[0]; // Validate file size if (file.size > maxTextFileSizeBytes) { const sizeMB = (file.size / (1024 * 1024)).toFixed(2); const limitMB = (maxTextFileSizeBytes / (1024 * 1024)).toFixed(1); fileError.set(`File too large: ${sizeMB}MB (limit: ${limitMB}MB)`); return; } // Validate file type (case-insensitive extension, allow empty MIME) const isTextFile = file.type.startsWith("text/") || file.type === "application/json" || file.type === "" || // Allow empty MIME if extension matches /\.(txt|md|csv|json)$/i.test(file.name); if (!isTextFile) { fileError.set(`Unsupported file type: ${file.name}`); return; } // Decode base64 data URL to text (UTF-8 safe) try { const textContent = decodeBase64ToText(file.data); previewText.set(textContent); previewSource.set("file"); previewFileName.set(file.name); // Clear any previous image upload so the label shows correctly uploadedImage.set([]); } catch (e) { console.error("Failed to decode text file:", e); fileError.set( `Failed to read file: ${ e instanceof Error ? e.message : "Unknown error" }`, ); } }, ); /** * Commit preview text to the main value * Reads from previewText (for file uploads) or ocrResult (for image OCR) */ const handleCommitPreview = handler< unknown, { $value: Cell; previewText: Cell; previewSource: Cell<"file" | "image" | null>; previewFileName: Cell; uploadedImage: Cell; // deno-lint-ignore no-explicit-any ocrResult: any; // The ocr.result reactive value } >( ( _event, { $value, previewText, previewSource, previewFileName, uploadedImage, ocrResult, }, ) => { const hasImage = uploadedImage.get().length > 0; const fileSource = previewSource.get() === "file"; let preview: string | null = null; if (fileSource) { preview = previewText.get(); } else if (hasImage && ocrResult) { // ocrResult is already the string value from generateText preview = ocrResult as string; } if (preview) { $value.set(preview); } // Clear preview state previewText.set(null); previewSource.set(null); previewFileName.set(null); uploadedImage.set([]); // Also clear image to reset OCR state }, ); /** * Cancel/dismiss preview and clear errors */ const handleCancelPreview = handler< unknown, { previewText: Cell; previewSource: Cell<"file" | "image" | null>; previewFileName: Cell; uploadedImage: Cell; // Image array to clear fileError: Cell; } >( ( _event, { previewText, previewSource, previewFileName, uploadedImage, fileError }, ) => { previewText.set(null); previewSource.set(null); previewFileName.set(null); uploadedImage.set([]); // Clear the image array fileError.set(null); }, ); /** * Handle image change from ct-image-input * NOTE: The $images binding handles actual data flow to the imageArray Cell. * This handler only sets UI state flags for the preview section. * The image data arrives via the binding, triggering the computed() for OCR. */ const handleImageChange = handler< { detail: { images: ImageData[] } }, { previewText: Cell; fileError: Cell; } >(({ detail }, { previewText, fileError }) => { // NOTE: The $images binding handles data flow - this handler only clears state // The handler fires before file processing completes, so we can't rely on // detail.images to detect uploads. derivedPreviewSource handles that reactively. const images = detail?.images; if (!images || images.length === 0) { // Image was removed - clear file errors fileError.set(null); return; } // Image added - clear file preview state (image preview is derived from imageArray) previewText.set(null); fileError.set(null); }); // ===== The Pattern ===== export function SmartTextInput( input: SmartTextInputInput, ): SmartTextInputOutput { const { $value, placeholder = DEFAULT_PLACEHOLDER, rows = 4, maxImageSizeBytes = DEFAULT_MAX_IMAGE_SIZE, maxTextFileSizeBytes = DEFAULT_MAX_TEXT_FILE_SIZE, } = input; // ===== Internal State ===== // Preview state (for file upload or OCR result) const previewText = Cell.of(null); const previewSource = Cell.of<"file" | "image" | null>(null); const previewFileName = Cell.of(null); // File error state (for user feedback) const fileError = Cell.of(null); // Image array for ct-image-input binding const imageArray = Cell.of([]); // ===== OCR Processing ===== // Build OCR prompt directly using computed() - this ensures reactivity // when the $images binding updates the imageArray Cell. // Pattern: Match image-analysis.tsx which uses computed() to build content parts const ocrPrompt = computed(() => { const images = imageArray.get(); const image = images.length > 0 ? images[0] : null; if (!image || !image.data) { // Return undefined when no image - generateText will early-exit gracefully return undefined; } return [ { type: "image" as const, image: image.data }, { type: "text" as const, text: "Extract all text from this image exactly as written.", }, ]; }); // OCR using generateText with vision model const ocr = generateText({ system: OCR_SYSTEM_PROMPT, prompt: ocrPrompt, model: "anthropic:claude-sonnet-4-5", }); // ===== Computed State ===== // Use computed() for reactive transformations (not derive() with side effects) // Compute the effective preview text - either from file upload or OCR result // IMPORTANT: Don't use .set() inside computed() - that's an anti-pattern! // Instead, compute the display value directly from sources const effectivePreviewText = computed(() => { const images = imageArray.get(); const hasImages = images.length > 0; const manualSource = previewSource.get(); // Determine effective source: "image" if images present, else manual source const source = hasImages ? "image" : manualSource; const filePreview = previewText.get(); const ocrResult = ocr.result; const ocrPending = ocr.pending; const image = images[0] ?? null; // If we have a file upload preview, use it if (source === "file" && filePreview) { return filePreview; } // If we have an OCR result and it's done processing, use it if (source === "image" && ocrResult && !ocrPending && image) { return ocrResult as string; } return null; }); // hasPreview checks if there's content to show (from file or OCR) const hasPreview = computed(() => { const images = imageArray.get(); const hasImages = images.length > 0; const manualSource = previewSource.get(); // Determine effective source: "image" if images present, else manual source const source = hasImages ? "image" : manualSource; const filePreview = previewText.get(); const ocrResult = ocr.result; const ocrPending = ocr.pending; const image = images[0] ?? null; if (source === "file" && filePreview) { return true; } if (source === "image" && ocrResult && !ocrPending && image) { return true; } return false; }); const isPending = computed(() => { const pending = ocr.pending; const image = imageArray.get()[0] ?? null; return Boolean(pending && image); }); // Only show error if there's an uploaded image (to avoid showing error for empty prompt) const hasError = computed(() => { const err = ocr.error; const image = imageArray.get()[0] ?? null; return Boolean(err && image); }); const errorMessage = computed(() => { const err = ocr.error; return err ? String(err) : null; }); // ===== UI Components ===== const textArea = ( ); // File error display const hasFileError = computed(() => fileError.get() !== null); const buttons = (
{/* File Upload Button */} {/* Image Upload Button */} {/* Using $images binding like image-analysis.tsx - this handles data flow */} {/* Handler only sets preview state flags, doesn't store image */}
); const preview = (
{/* Loading state */} {ifElse( isPending,
Extracting text from image...
, null, )} {/* File error state */} {ifElse( hasFileError,
{fileError}
, null, )} {/* OCR error state */} {ifElse( hasError,
OCR failed: {errorMessage}
, null, )} {/* Preview with commit/cancel buttons */} {ifElse( hasPreview,
{ifElse( computed(() => previewSource.get() === "file"), 📄 From file: {previewFileName}, 📷 Extracted from image, )}
{effectivePreviewText}
, null, )}
); const complete = (
{textArea} {buttons} {preview}
); // ===== Return ===== return { value: $value, pending: isPending as unknown as boolean, error: errorMessage as unknown as string | null, ui: { complete, textArea, buttons, preview, }, }; } export default SmartTextInput;