import { css, html, type PropertyValues } from "lit"; import { state } from "lit/decorators.js"; import { BaseElement } from "../../core/base-element.ts"; import { type CellHandle } from "@commonfabric/runtime-client"; import { createStringCellController } from "../../core/cell-controller.ts"; import "../cf-button/index.ts"; /** * File System Access API types * These are not yet in all TypeScript lib versions */ interface FileSystemWritableFileStream extends WritableStream { write(data: BufferSource | Blob | string): Promise; close(): Promise; } interface FileSystemFileHandle { createWritable(): Promise; } interface FileSystemDirectoryHandle { name: string; getFileHandle( name: string, options?: { create?: boolean }, ): Promise; } declare global { var showDirectoryPicker: () => Promise; } /** * Check if File System Access API is available */ const hasFileSystemAccess = (): boolean => { return "showDirectoryPicker" in globalThis; }; /** * MIME type to file extension mapping */ const MIME_EXTENSIONS: Record = { "application/json": "json", "text/plain": "txt", "text/csv": "csv", "text/markdown": "md", "text/html": "html", "application/pdf": "pdf", "image/png": "png", "image/jpeg": "jpg", "image/svg+xml": "svg", "application/xml": "xml", "application/octet-stream": "bin", }; /** * CFFileDownload - File download button with automatic visual feedback * * Triggers a file download from string data. The component encapsulates * the blob/ObjectURL/anchor download pattern, allowing patterns to trigger * downloads without directly accessing globalThis or browser DOM APIs. * * @element cf-file-download * * @property {string|CellHandle} data - Content to download (required) * @property {string|CellHandle} filename - Download filename (auto-generated if not provided) * @attr {string} mime-type - MIME type for the file (default: "application/octet-stream") * @attr {boolean} base64 - If true, decode data as base64 before downloading (default: false) * @attr {string} variant - Button style variant (default: "secondary") * Options: "primary" | "secondary" | "destructive" | "outline" | "ghost" | "link" | "pill" * @attr {string} size - Button size (default: "md") * Options: "xs" | "sm" | "md" | "lg" | "xl" | "icon" * @attr {boolean} disabled - Disable the button * @attr {number} feedback-duration - Success feedback duration in ms (default: 2000) * @attr {boolean} icon-only - Only show icon, no text (default: false) * @attr {boolean} allow-autosave - Enable Option+click to activate auto-save mode (default: false) * * @fires cf-download-success - Fired when download succeeds * Detail: { filename: string, size: number, mimeType: string } * @fires cf-download-error - Fired when download fails * Detail: { error: Error, filename: string } * @fires cf-autosave-enabled - Fired when auto-save mode is activated * Detail: { directoryName: string } * @fires cf-autosave-disabled - Fired when auto-save mode is deactivated * @fires cf-autosave-success - Fired when auto-save completes successfully * Detail: { filename: string, size: number } * @fires cf-autosave-error - Fired when auto-save fails * Detail: { error: Error } * * @slot - Button label text (optional, defaults based on state) * * @example * // Basic usage * Download * * // With Cell binding (in pattern) * Export * * // Icon only * * * // Base64 binary data * Download Image */ export class CFFileDownload extends BaseElement { static override styles = [ BaseElement.baseStyles, css` :host { position: relative; display: inline-block; } /* Ensure icon-only buttons maintain square aspect ratio */ :host([icon-only]) cf-button { min-width: 2.25rem; display: inline-flex; } /* Adjust for different sizes when icon-only */ :host([icon-only]) cf-button::part(button) { aspect-ratio: 1; min-width: fit-content; } /* Autosave indicator dot */ .autosave-indicator { position: absolute; top: -2px; right: -2px; width: 8px; height: 8px; border-radius: 50%; pointer-events: none; z-index: 1; } .autosave-indicator.saved { background-color: var( --cf-theme-color-success, var(--cf-colors-success, #22c55e) ); } .autosave-indicator.pending { background-color: var( --cf-theme-color-warning, var(--cf-colors-warning, #f59e0b) ); animation: gentle-pulse 2s ease-in-out infinite; } .autosave-indicator.saving { background-color: var( --cf-theme-color-primary, var(--cf-colors-info, #3b82f6) ); } @keyframes gentle-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } /* Shake animation for "not available" feedback */ @keyframes shake { 0%, 100% { transform: translateX(0); } 20%, 60% { transform: translateX(-4px); } 40%, 80% { transform: translateX(4px); } } :host(.shake) { animation: shake 0.4s ease-in-out; } /* Tooltip for autosave status */ .autosave-tooltip { position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); padding: 4px 8px; background: var( --cf-theme-color-text, var(--cf-colors-gray-900, #16181d) ); color: var(--cf-theme-color-background, var(--cf-colors-gray-50, #ffffff)); font-size: 12px; border-radius: 4px; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity 0.2s; margin-bottom: 4px; z-index: 10; } :host(:hover) .autosave-tooltip { opacity: 1; } `, ]; static override properties = { data: { attribute: false }, filename: { attribute: false }, mimeType: { type: String, attribute: "mime-type" }, base64: { type: Boolean }, variant: { type: String }, size: { type: String }, disabled: { type: Boolean, reflect: true }, feedbackDuration: { type: Number, attribute: "feedback-duration" }, iconOnly: { type: Boolean, attribute: "icon-only", reflect: true }, allowAutosave: { type: Boolean, attribute: "allow-autosave", reflect: true, }, }; declare data: CellHandle | string; declare filename: CellHandle | string; declare mimeType: string; declare base64: boolean; declare variant?: | "primary" | "secondary" | "destructive" | "outline" | "ghost" | "link" | "pill"; declare size?: "xs" | "sm" | "md" | "lg" | "xl" | "icon"; declare disabled: boolean; declare feedbackDuration: number; declare iconOnly: boolean; declare allowAutosave: boolean; @state() private accessor _downloaded = false; @state() private accessor _downloading = false; private _resetTimeout?: ReturnType; // Autosave state @state() private accessor _autosaveEnabled = false; private _autosaveDirHandle: FileSystemDirectoryHandle | null = null; private _autosaveTimer: ReturnType | null = null; @state() private accessor _isDirty = false; private _lastSavedData: string | null = null; @state() private accessor _isSavingAutosave = false; @state() private accessor _showNotAvailableTooltip = false; @state() private accessor _notAvailableMessage = ""; private _notAvailableTooltipTimeout?: ReturnType; /** Maximum file size in bytes (100MB) */ private static readonly MAX_FILE_SIZE = 100 * 1024 * 1024; /** Delay before revoking object URL to ensure download starts (ms) */ private static readonly URL_REVOKE_DELAY = 100; /** Auto-save interval in milliseconds (60 seconds) */ private static readonly AUTOSAVE_INTERVAL = 60_000; /** CellController for data property */ private _dataController = createStringCellController(this, { timing: { strategy: "immediate" }, }); /** CellController for filename property */ private _filenameController = createStringCellController(this, { timing: { strategy: "immediate" }, }); constructor() { super(); this.data = ""; this.filename = ""; this.mimeType = "application/octet-stream"; this.base64 = false; this.variant = "secondary"; this.size = "md"; this.disabled = false; this.feedbackDuration = 2000; this.iconOnly = false; this.allowAutosave = false; } /** * Get the data value (string content to download) */ private _getDataValue(): string { return this._dataController.getValue() ?? ""; } /** * Sanitize filename to remove potentially problematic characters */ private _sanitizeFilename(filename: string): string { // Remove path traversal attempts and problematic characters return filename .replace(/\.\./g, "_") // No path traversal // deno-lint-ignore no-control-regex .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") // No special chars .slice(0, 255); // Max filename length } /** * Get the filename, auto-generating if not provided */ private _getFilename(): string { const fn = this._filenameController.getValue(); if (fn) return this._sanitizeFilename(fn); // Auto-generate filename with timestamp const timestamp = new Date() .toISOString() .replace(/[:.]/g, "-") .slice(0, 19); const ext = MIME_EXTENSIONS[this.mimeType] || "bin"; return `download-${timestamp}.${ext}`; } /** * Create a Blob from the data, optionally decoding base64 */ private _createBlob(data: string): Blob { if (this.base64) { try { // Trim whitespace that may be present in base64 data from various sources const binaryString = atob(data.trim()); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return new Blob([bytes], { type: this.mimeType }); } catch (e) { throw new Error(`Invalid base64 data: ${(e as Error).message}`); } } return new Blob([data], { type: this.mimeType }); } override willUpdate(changedProperties: PropertyValues) { super.willUpdate(changedProperties); // Bind CellControllers when properties change if (changedProperties.has("data")) { this._dataController.bind(this.data); } if (changedProperties.has("filename")) { this._filenameController.bind(this.filename); } } override updated(_changedProperties: PropertyValues) { // Track data changes for autosave (side effect belongs in updated) if (this._autosaveEnabled) { const currentData = this._getDataValue(); if (currentData !== this._lastSavedData) { this._isDirty = true; this._scheduleAutosave(); } } } override connectedCallback() { super.connectedCallback(); // Add visibility change listener for auto-save on tab switch document.addEventListener("visibilitychange", this._handleVisibilityChange); globalThis.addEventListener("beforeunload", this._handleBeforeUnload); } override disconnectedCallback() { super.disconnectedCallback(); if (this._resetTimeout) { clearTimeout(this._resetTimeout); } if (this._autosaveTimer) { clearTimeout(this._autosaveTimer); } if (this._notAvailableTooltipTimeout) { clearTimeout(this._notAvailableTooltipTimeout); } // Remove event listeners document.removeEventListener( "visibilitychange", this._handleVisibilityChange, ); globalThis.removeEventListener("beforeunload", this._handleBeforeUnload); } /** * Handle visibility change - save immediately when tab becomes hidden */ private _handleVisibilityChange = () => { if (document.hidden && this._autosaveEnabled && this._isDirty) { this._performAutosave(); } }; /** * Handle beforeunload - warn user if there are unsaved changes */ private _handleBeforeUnload = (e: BeforeUnloadEvent) => { if (this._autosaveEnabled && this._isDirty) { // Attempt to save (may not complete) this._performAutosave(); // Show browser's default "unsaved changes" dialog e.preventDefault(); e.returnValue = ""; } }; /** * Enable autosave mode by prompting user for folder */ private async _enableAutosave(): Promise { if (!hasFileSystemAccess()) { this._showNotAvailableFeedback("Auto-save requires Chrome or Edge"); return false; } try { // Prompt user to select a folder const dirHandle = await globalThis.showDirectoryPicker(); this._autosaveDirHandle = dirHandle; this._autosaveEnabled = true; this._lastSavedData = this._getDataValue(); this._isDirty = false; this.emit("cf-autosave-enabled", { directoryName: dirHandle.name, }); return true; } catch (error) { // User cancelled or permission denied if ((error as Error).name !== "AbortError") { this._showNotAvailableFeedback("Could not access folder"); } return false; } } /** * Disable autosave mode */ private _disableAutosave() { this._autosaveEnabled = false; this._autosaveDirHandle = null; this._isDirty = false; if (this._autosaveTimer) { clearTimeout(this._autosaveTimer); this._autosaveTimer = null; } this.emit("cf-autosave-disabled", {}); } /** * Perform the actual autosave to the selected folder */ private async _performAutosave(): Promise { if (!this._autosaveDirHandle || this._isSavingAutosave) return; const data = this._getDataValue(); if (!data) return; this._isSavingAutosave = true; try { const blob = this._createBlob(data); // Check file size limit to prevent hanging on huge files if (blob.size > CFFileDownload.MAX_FILE_SIZE) { throw new Error( `File size (${ Math.round(blob.size / 1024 / 1024) }MB) exceeds maximum allowed (${ Math.round(CFFileDownload.MAX_FILE_SIZE / 1024 / 1024) }MB)`, ); } // Generate timestamped filename const timestamp = new Date() .toISOString() .replace(/[:.]/g, "-") .slice(0, 19); const baseName = this._filenameController.getValue() || "backup"; const ext = MIME_EXTENSIONS[this.mimeType] || "bin"; const sanitizedBase = this._sanitizeFilename( baseName.replace(/\.[^.]+$/, ""), ); const filename = `${sanitizedBase}-${timestamp}.${ext}`; // Write to file const fileHandle = await this._autosaveDirHandle.getFileHandle(filename, { create: true, }); const writable = await fileHandle.createWritable(); await writable.write(blob); await writable.close(); // Update state - check if data changed during save (race condition) const currentData = this._getDataValue(); this._lastSavedData = data; this._isSavingAutosave = false; if (currentData !== data) { // Data changed during save, keep dirty and reschedule this._isDirty = true; this._scheduleAutosave(); } else { this._isDirty = false; // Clear timer since we just saved and data is current if (this._autosaveTimer) { clearTimeout(this._autosaveTimer); this._autosaveTimer = null; } } this.emit("cf-autosave-success", { filename, size: blob.size, }); } catch (error) { this._isSavingAutosave = false; // Check if permission was revoked if ((error as Error).name === "NotAllowedError") { this._disableAutosave(); this._showNotAvailableFeedback("Folder access revoked"); } this.emit("cf-autosave-error", { error: error as Error, }); } } /** * Start or reset the autosave timer */ private _scheduleAutosave() { if (!this._autosaveEnabled) return; if (this._autosaveTimer) { clearTimeout(this._autosaveTimer); } this._autosaveTimer = setTimeout(() => { this._performAutosave(); }, CFFileDownload.AUTOSAVE_INTERVAL); } /** * Show "not available" feedback with shake animation and tooltip */ private _showNotAvailableFeedback(message: string) { this._showNotAvailableTooltip = true; this._notAvailableMessage = message; this.classList.add("shake"); if (this._notAvailableTooltipTimeout) { clearTimeout(this._notAvailableTooltipTimeout); } this._notAvailableTooltipTimeout = setTimeout(() => { this._showNotAvailableTooltip = false; this.classList.remove("shake"); }, 2000); } private _handleClick(e: Event) { e.preventDefault(); e.stopPropagation(); // Guard against rapid clicks and disabled state if (this.disabled || this._downloading) return; const mouseEvent = e as MouseEvent; const isOptionClick = mouseEvent.altKey; // Handle Option+click for autosave toggle if (isOptionClick) { if (!this.allowAutosave) { // Show feedback that autosave is not available for this button this._showNotAvailableFeedback( "Auto-save not available for this download", ); // Continue with normal download } else if (this._autosaveEnabled) { // Toggle off this._disableAutosave(); return; } else { // Toggle on - prompt for folder this._enableAutosave(); return; } } // If autosave is enabled, save to folder instead of browser download if (this._autosaveEnabled) { this._performAutosave(); return; } const data = this._getDataValue(); const filename = this._getFilename(); // Check for empty data if (!data) { this.emit("cf-download-error", { error: new Error("No data to download"), filename, }); return; } // Set downloading state to prevent rapid clicks this._downloading = true; try { // Create blob from data const blob = this._createBlob(data); // Check file size limit if (blob.size > CFFileDownload.MAX_FILE_SIZE) { throw new Error( `File size (${ Math.round(blob.size / 1024 / 1024) }MB) exceeds maximum allowed (${ Math.round(CFFileDownload.MAX_FILE_SIZE / 1024 / 1024) }MB)`, ); } const url = URL.createObjectURL(blob); // Create and trigger download via anchor element const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); // Delay URL revocation to ensure download starts in all browsers setTimeout( () => URL.revokeObjectURL(url), CFFileDownload.URL_REVOKE_DELAY, ); // Update state for visual feedback this._downloaded = true; this._downloading = false; this.emit("cf-download-success", { filename, size: blob.size, mimeType: this.mimeType, }); // Reset downloaded state after duration if (this._resetTimeout) { clearTimeout(this._resetTimeout); } this._resetTimeout = setTimeout(() => { this._downloaded = false; }, this.feedbackDuration); } catch (error) { this._downloading = false; this.emit("cf-download-error", { error: error as Error, filename, }); } } /** * Get the autosave indicator state class */ private _getAutosaveIndicatorClass(): string { if (!this._autosaveEnabled) return ""; if (this._isSavingAutosave) return "saving"; if (this._isDirty) return "pending"; return "saved"; } /** * Get the tooltip text for autosave state */ private _getAutosaveTooltip(): string { if (this._showNotAvailableTooltip) { return this._notAvailableMessage; } if (!this._autosaveEnabled) return ""; if (this._isSavingAutosave) return "Saving..."; if (this._isDirty) return "Auto-save on · Saving soon..."; return "Auto-save on · All changes saved"; } override render() { const hasData = !!this._getDataValue(); // Determine title and aria-label based on state let title: string; let ariaLabel: string; if (this._autosaveEnabled) { title = this._getAutosaveTooltip(); ariaLabel = title; } else if (this._downloading) { title = "Downloading..."; ariaLabel = "Downloading file"; } else if (this._downloaded) { title = "Downloaded!"; ariaLabel = "File downloaded"; } else if (this.allowAutosave) { title = "Download file · Option+click for auto-save"; ariaLabel = "Download file, option click to enable auto-save"; } else { title = "Download file"; ariaLabel = "Download file"; } // Determine icon const icon = this._autosaveEnabled ? "\uD83D\uDD04" // 🔄 : this._downloading ? "\u23F3" // ⏳ : this._downloaded ? "\u2713" // ✓ : "\u2B07"; // ⬇ // Determine button text const buttonText = this._autosaveEnabled ? this._isSavingAutosave ? "\uD83D\uDD04 Saving..." : "\uD83D\uDD04 Auto-save" : this._downloading ? "\u23F3 Downloading..." : this._downloaded ? "\u2713 Downloaded!" : "\u2B07 Download"; const indicatorClass = this._getAutosaveIndicatorClass(); const tooltipText = this._showNotAvailableTooltip || this._autosaveEnabled ? this._getAutosaveTooltip() : ""; return html` ${indicatorClass ? html` ` : null} ${tooltipText ? html` ${tooltipText} ` : null} ${this.iconOnly ? html` ${icon} ` : html` ${buttonText} `} `; } }