From 4629615ebb95531ae2f5f987783b1911560d2be9 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Thu, 21 May 2026 08:53:21 +0200 Subject: [PATCH 01/13] [STEP-2.12-001] Add files module types, interfaces, and config token Define foundational types for the Files module (EXTENDED #17): ExportFormat, UploadProgress, FileValidationConfig, ValidationResult, ExportColumn, ExportConfig, FilesConfig, and FILES_CONFIG InjectionToken. --- packages/core/src/lib/files/file.types.ts | 196 ++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 packages/core/src/lib/files/file.types.ts diff --git a/packages/core/src/lib/files/file.types.ts b/packages/core/src/lib/files/file.types.ts new file mode 100644 index 0000000..3968de6 --- /dev/null +++ b/packages/core/src/lib/files/file.types.ts @@ -0,0 +1,196 @@ +import { InjectionToken } from '@angular/core'; + +// --------------------------------------------------------------------------- +// Export format +// --------------------------------------------------------------------------- + +/** + * Supported export formats for tabular data. + * + * - `'csv'` — Comma-separated values (pure JS, no dependencies) + * - `'pdf'` — PDF document via `jspdf` (optional peer dependency) + * - `'xlsx'` — Excel workbook via `exceljs` (optional peer dependency) + */ +export type ExportFormat = 'csv' | 'pdf' | 'xlsx'; + +// --------------------------------------------------------------------------- +// Upload progress +// --------------------------------------------------------------------------- + +/** + * Tracks the progress of a file upload. + * + * Emitted by `FileUploadService.upload()` as an `Observable`. + * + * @example + * ```typescript + * uploader.upload(file, '/api/files').subscribe(progress => { + * console.log(`${progress.percent}%`); + * if (progress.status === 'complete') { ... } + * }); + * ``` + */ +export interface UploadProgress { + /** Percentage completed (0–100). */ + readonly percent: number; + + /** Total bytes to upload (`undefined` if server doesn't report). */ + readonly totalBytes?: number; + + /** Bytes uploaded so far. */ + readonly loadedBytes: number; + + /** Current status of the upload. */ + readonly status: 'pending' | 'uploading' | 'complete' | 'error'; + + /** Server response body (only when `status === 'complete'`). */ + readonly response?: unknown; + + /** Error message (only when `status === 'error'`). */ + readonly error?: string; +} + +// --------------------------------------------------------------------------- +// File validation +// --------------------------------------------------------------------------- + +/** + * Rules for validating a file before upload or processing. + * + * All fields are optional — only specified rules are enforced. + * + * @example + * ```typescript + * const rules: FileValidationConfig = { + * maxSizeBytes: 10 * 1024 * 1024, + * allowedMimeTypes: ['application/pdf', 'image/png'], + * allowedExtensions: ['.pdf', '.png'], + * }; + * ``` + */ +export interface FileValidationConfig { + /** Maximum file size in bytes. */ + readonly maxSizeBytes?: number; + + /** Allowed MIME types (e.g. `'application/pdf'`, `'image/*'`). */ + readonly allowedMimeTypes?: string[]; + + /** Allowed file extensions including dot (e.g. `'.pdf'`, `'.png'`). */ + readonly allowedExtensions?: string[]; +} + +/** + * Result of file validation. + * + * @example + * ```typescript + * const result = validator.validate(file, rules); + * if (!result.valid) { + * console.error(result.errors); + * } + * ``` + */ +export interface ValidationResult { + /** Whether the file passed all validation rules. */ + readonly valid: boolean; + + /** List of validation error messages (empty when valid). */ + readonly errors: string[]; +} + +// --------------------------------------------------------------------------- +// Export configuration +// --------------------------------------------------------------------------- + +/** + * Column definition for tabular data export. + * + * Maps a data property to a column header in the exported file. + */ +export interface ExportColumn { + /** Property key to extract from each data row. */ + readonly key: keyof T & string; + + /** Display header text for this column. */ + readonly header: string; + + /** Column width (interpretation depends on format: chars for CSV, pts for PDF/Excel). */ + readonly width?: number; +} + +/** + * Configuration for exporting tabular data to a file. + * + * @example + * ```typescript + * exportService.export(users, { + * format: 'csv', + * filename: 'users-report', + * columns: [ + * { key: 'name', header: 'Full Name' }, + * { key: 'email', header: 'Email Address' }, + * ], + * }); + * ``` + */ +export interface ExportConfig { + /** Output format. */ + readonly format: ExportFormat; + + /** Filename without extension (extension is added automatically). */ + readonly filename: string; + + /** Column definitions mapping data keys to headers. */ + readonly columns: ExportColumn[]; + + /** Document title (used in PDF header and Excel sheet title). */ + readonly title?: string; + + /** Sheet name for Excel exports. Default: `'Sheet1'`. */ + readonly sheetName?: string; + + /** Field separator for CSV exports. Default: `','`. */ + readonly csvSeparator?: string; +} + +// --------------------------------------------------------------------------- +// Module configuration +// --------------------------------------------------------------------------- + +/** + * Configuration for `provideFiles()`. + * + * Controls file upload limits, allowed types, and preview behavior. + * All fields are optional — sensible defaults are applied. + * + * @example + * ```typescript + * provideFiles({ + * maxFileSizeBytes: 10 * 1024 * 1024, + * allowedMimeTypes: ['application/pdf', 'image/png', 'image/jpeg'], + * }) + * ``` + */ +export interface FilesConfig { + /** Maximum file size in bytes. Default: 10 MB (10 * 1024 * 1024). */ + readonly maxFileSizeBytes: number; + + /** Allowed MIME types. Default: `[]` (no restriction). */ + readonly allowedMimeTypes: string[]; + + /** Maximum number of concurrent uploads. Default: `5`. */ + readonly maxConcurrentUploads: number; + + /** Override upload endpoint (bypasses global apiBaseUrl). */ + readonly uploadEndpoint?: string; + + /** Enable inline file previews. Default: `true`. */ + readonly enablePreview: boolean; +} + +/** + * Injection token for the files module configuration. + * + * Provided by `provideFiles()`. Services inject this to read config. + */ +export const FILES_CONFIG = new InjectionToken('FILES_CONFIG'); From 4143bf6b9b7ff6f7cca3623609e042ce7c1173ea Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Thu, 21 May 2026 08:58:47 +0200 Subject: [PATCH 02/13] [STEP-2.12-002] Add FileValidationService with MIME, size, and extension validation Validates files against configurable rules (maxSizeBytes, allowedMimeTypes, allowedExtensions). Supports wildcard MIME patterns (image/*), case-insensitive extensions, and falls back to FILES_CONFIG module defaults. 16 tests. --- .../lib/files/file-validation.service.spec.ts | 197 ++++++++++++++++++ .../src/lib/files/file-validation.service.ts | 74 +++++++ 2 files changed, 271 insertions(+) create mode 100644 packages/core/src/lib/files/file-validation.service.spec.ts create mode 100644 packages/core/src/lib/files/file-validation.service.ts diff --git a/packages/core/src/lib/files/file-validation.service.spec.ts b/packages/core/src/lib/files/file-validation.service.spec.ts new file mode 100644 index 0000000..633a4df --- /dev/null +++ b/packages/core/src/lib/files/file-validation.service.spec.ts @@ -0,0 +1,197 @@ +import { TestBed } from '@angular/core/testing'; +import { FileValidationService } from './file-validation.service'; +import { FILES_CONFIG, FilesConfig } from './file.types'; + +function createFile(name: string, size: number, type: string): File { + const content = new ArrayBuffer(size); + return new File([content], name, { type }); +} + +describe('FileValidationService', () => { + function setup(config?: Partial) { + TestBed.configureTestingModule({ + providers: [ + FileValidationService, + ...(config + ? [{ provide: FILES_CONFIG, useValue: config }] + : []), + ], + }); + return TestBed.inject(FileValidationService); + } + + // ------------------------------------------------------------------- + // Valid files + // ------------------------------------------------------------------- + it('should return valid for a file with no rules', () => { + const svc = setup(); + const result = svc.validate(createFile('doc.pdf', 1024, 'application/pdf')); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should return valid when file passes all rules', () => { + const svc = setup(); + const result = svc.validate(createFile('photo.png', 500, 'image/png'), { + maxSizeBytes: 1024, + allowedMimeTypes: ['image/png', 'image/jpeg'], + allowedExtensions: ['.png', '.jpg'], + }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + // ------------------------------------------------------------------- + // Size validation + // ------------------------------------------------------------------- + it('should reject file exceeding maxSizeBytes', () => { + const svc = setup(); + const result = svc.validate(createFile('big.pdf', 2000, 'application/pdf'), { + maxSizeBytes: 1000, + }); + expect(result.valid).toBe(false); + expect(result.errors.length).toBe(1); + expect(result.errors[0]).toContain('2000'); + expect(result.errors[0]).toContain('1000'); + }); + + it('should accept file at exact maxSizeBytes limit', () => { + const svc = setup(); + const result = svc.validate(createFile('exact.pdf', 1000, 'application/pdf'), { + maxSizeBytes: 1000, + }); + expect(result.valid).toBe(true); + }); + + // ------------------------------------------------------------------- + // MIME type validation + // ------------------------------------------------------------------- + it('should reject disallowed MIME type', () => { + const svc = setup(); + const result = svc.validate(createFile('doc.exe', 100, 'application/x-msdownload'), { + allowedMimeTypes: ['application/pdf', 'image/png'], + }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('application/x-msdownload'); + expect(result.errors[0]).toContain('not allowed'); + }); + + it('should support wildcard MIME patterns (image/*)', () => { + const svc = setup(); + const result = svc.validate(createFile('photo.webp', 100, 'image/webp'), { + allowedMimeTypes: ['image/*'], + }); + expect(result.valid).toBe(true); + }); + + it('should reject non-matching wildcard MIME', () => { + const svc = setup(); + const result = svc.validate(createFile('doc.pdf', 100, 'application/pdf'), { + allowedMimeTypes: ['image/*'], + }); + expect(result.valid).toBe(false); + }); + + it('should handle file with empty MIME type', () => { + const svc = setup(); + const result = svc.validate(createFile('unknown', 100, ''), { + allowedMimeTypes: ['application/pdf'], + }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('unknown'); + }); + + // ------------------------------------------------------------------- + // Extension validation + // ------------------------------------------------------------------- + it('should reject disallowed extension', () => { + const svc = setup(); + const result = svc.validate(createFile('virus.exe', 100, 'application/x-msdownload'), { + allowedExtensions: ['.pdf', '.png'], + }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('.exe'); + }); + + it('should be case-insensitive for extensions', () => { + const svc = setup(); + const result = svc.validate(createFile('PHOTO.PNG', 100, 'image/png'), { + allowedExtensions: ['.png'], + }); + expect(result.valid).toBe(true); + }); + + it('should handle file without extension', () => { + const svc = setup(); + const result = svc.validate(createFile('noext', 100, 'text/plain'), { + allowedExtensions: ['.txt'], + }); + expect(result.valid).toBe(false); + }); + + // ------------------------------------------------------------------- + // Multiple errors + // ------------------------------------------------------------------- + it('should collect multiple errors at once', () => { + const svc = setup(); + const result = svc.validate(createFile('bad.exe', 5000, 'application/x-msdownload'), { + maxSizeBytes: 1000, + allowedMimeTypes: ['application/pdf'], + allowedExtensions: ['.pdf'], + }); + expect(result.valid).toBe(false); + expect(result.errors.length).toBe(3); + }); + + // ------------------------------------------------------------------- + // Module-level defaults from FILES_CONFIG + // ------------------------------------------------------------------- + it('should use FILES_CONFIG maxFileSizeBytes as default', () => { + const svc = setup({ + maxFileSizeBytes: 500, + allowedMimeTypes: [], + maxConcurrentUploads: 5, + enablePreview: true, + }); + const result = svc.validate(createFile('big.pdf', 1000, 'application/pdf')); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('500'); + }); + + it('should use FILES_CONFIG allowedMimeTypes as default', () => { + const svc = setup({ + maxFileSizeBytes: 10_000_000, + allowedMimeTypes: ['image/png'], + maxConcurrentUploads: 5, + enablePreview: true, + }); + const result = svc.validate(createFile('doc.pdf', 100, 'application/pdf')); + expect(result.valid).toBe(false); + }); + + it('should prefer per-call rules over FILES_CONFIG', () => { + const svc = setup({ + maxFileSizeBytes: 100, + allowedMimeTypes: ['image/png'], + maxConcurrentUploads: 5, + enablePreview: true, + }); + // Per-call rules override: bigger size, different MIME + const result = svc.validate(createFile('doc.pdf', 500, 'application/pdf'), { + maxSizeBytes: 1000, + allowedMimeTypes: ['application/pdf'], + }); + expect(result.valid).toBe(true); + }); + + it('should skip MIME check when FILES_CONFIG has empty allowedMimeTypes', () => { + const svc = setup({ + maxFileSizeBytes: 10_000_000, + allowedMimeTypes: [], + maxConcurrentUploads: 5, + enablePreview: true, + }); + const result = svc.validate(createFile('anything.xyz', 100, 'application/octet-stream')); + expect(result.valid).toBe(true); + }); +}); diff --git a/packages/core/src/lib/files/file-validation.service.ts b/packages/core/src/lib/files/file-validation.service.ts new file mode 100644 index 0000000..f1c22c3 --- /dev/null +++ b/packages/core/src/lib/files/file-validation.service.ts @@ -0,0 +1,74 @@ +import { Injectable, inject } from '@angular/core'; +import { FILES_CONFIG, FileValidationConfig, ValidationResult } from './file.types'; + +/** + * Validates files against configurable rules before upload or processing. + * + * Checks MIME type, file size, and file extension. + * Module-level defaults come from `FilesConfig` (via `provideFiles()`). + * Per-call rules in `FileValidationConfig` take precedence. + * + * @example + * ```typescript + * const result = validator.validate(file, { + * maxSizeBytes: 5 * 1024 * 1024, + * allowedMimeTypes: ['application/pdf'], + * }); + * if (!result.valid) console.error(result.errors); + * ``` + */ +@Injectable() +export class FileValidationService { + private readonly config = inject(FILES_CONFIG, { optional: true }); + + /** + * Validate a file against the given rules. + * + * If no rules are provided, module-level defaults from `FilesConfig` apply. + * Returns `{ valid: true, errors: [] }` when all checks pass. + */ + validate(file: File, rules?: FileValidationConfig): ValidationResult { + const errors: string[] = []; + + const maxSize = rules?.maxSizeBytes ?? this.config?.maxFileSizeBytes; + const allowedMimes = rules?.allowedMimeTypes ?? (this.config?.allowedMimeTypes.length ? this.config.allowedMimeTypes : undefined); + const allowedExts = rules?.allowedExtensions; + + if (maxSize != null && file.size > maxSize) { + errors.push(`File size ${file.size} bytes exceeds maximum ${maxSize} bytes`); + } + + if (allowedMimes != null && allowedMimes.length > 0) { + const matches = allowedMimes.some(pattern => this.mimeMatches(file.type, pattern)); + if (!matches) { + errors.push(`File type '${file.type || 'unknown'}' is not allowed. Allowed: ${allowedMimes.join(', ')}`); + } + } + + if (allowedExts != null && allowedExts.length > 0) { + const ext = this.getExtension(file.name); + const normalizedAllowed = allowedExts.map(e => e.toLowerCase()); + if (!normalizedAllowed.includes(ext.toLowerCase())) { + errors.push(`File extension '${ext}' is not allowed. Allowed: ${allowedExts.join(', ')}`); + } + } + + return { valid: errors.length === 0, errors }; + } + + /** Match a MIME type against a pattern (supports wildcards like `image/*`). */ + private mimeMatches(mime: string, pattern: string): boolean { + if (pattern === '*/*') return true; + if (pattern.endsWith('/*')) { + const prefix = pattern.slice(0, pattern.indexOf('/')); + return mime.startsWith(prefix + '/'); + } + return mime === pattern; + } + + /** Extract file extension including dot. Returns empty string if no extension. */ + private getExtension(filename: string): string { + const idx = filename.lastIndexOf('.'); + return idx >= 0 ? filename.slice(idx) : ''; + } +} From 8c8cbd40226591a00b82c3ea6a5ddd5c143a3b37 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Thu, 21 May 2026 09:02:16 +0200 Subject: [PATCH 03/13] [STEP-2.12-003] Add FilePickerService with native file picker integration Programmatically creates hidden input[type=file], triggers click, and resolves with selected files. Includes pickImage() shorthand for image/* filtering. SSR-safe via inject(DOCUMENT). Cleanup after use. 11 tests. --- .../src/lib/files/file-picker.service.spec.ts | 196 ++++++++++++++++++ .../core/src/lib/files/file-picker.service.ts | 70 +++++++ 2 files changed, 266 insertions(+) create mode 100644 packages/core/src/lib/files/file-picker.service.spec.ts create mode 100644 packages/core/src/lib/files/file-picker.service.ts diff --git a/packages/core/src/lib/files/file-picker.service.spec.ts b/packages/core/src/lib/files/file-picker.service.spec.ts new file mode 100644 index 0000000..3f68e9b --- /dev/null +++ b/packages/core/src/lib/files/file-picker.service.spec.ts @@ -0,0 +1,196 @@ +import { TestBed } from '@angular/core/testing'; +import { DOCUMENT } from '@angular/common'; +import { FilePickerService } from './file-picker.service'; + +describe('FilePickerService', () => { + let mockInput: { + type: string; + accept: string; + multiple: boolean; + style: { display: string }; + files: FileList | null; + click: ReturnType; + addEventListener: ReturnType; + removeEventListener: ReturnType; + parentNode: { removeChild: ReturnType } | null; + }; + let listeners: Record; + let mockDoc: { + createElement: ReturnType; + body: { appendChild: ReturnType }; + }; + + function setup() { + listeners = {}; + mockInput = { + type: '', + accept: '', + multiple: false, + style: { display: '' }, + files: null, + click: vi.fn(), + addEventListener: vi.fn((event: string, fn: Function) => { + listeners[event] = fn; + }), + removeEventListener: vi.fn(), + parentNode: { removeChild: vi.fn() }, + }; + + mockDoc = { + createElement: vi.fn().mockReturnValue(mockInput), + body: { appendChild: vi.fn() }, + }; + + TestBed.configureTestingModule({ + providers: [ + FilePickerService, + { provide: DOCUMENT, useValue: mockDoc }, + ], + }); + return TestBed.inject(FilePickerService); + } + + function createFileList(files: File[]): FileList { + const list = { + length: files.length, + item: (i: number) => files[i] ?? null, + [Symbol.iterator]: function* () { yield* files; }, + } as unknown as FileList; + for (let i = 0; i < files.length; i++) { + (list as any)[i] = files[i]; + } + return list; + } + + // ------------------------------------------------------------------- + // pickFile + // ------------------------------------------------------------------- + it('should create a hidden file input and click it', async () => { + const svc = setup(); + const promise = svc.pickFile(); + + expect(mockDoc.createElement).toHaveBeenCalledWith('input'); + expect(mockInput.type).toBe('file'); + expect(mockInput.style.display).toBe('none'); + expect(mockDoc.body.appendChild).toHaveBeenCalledWith(mockInput); + expect(mockInput.click).toHaveBeenCalled(); + + // Simulate user selecting a file + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + mockInput.files = createFileList([file]); + listeners['change'](); + + const result = await promise; + expect(result).toEqual([file]); + }); + + it('should set accept attribute when provided', async () => { + const svc = setup(); + const promise = svc.pickFile('image/*'); + mockInput.files = createFileList([]); + listeners['change'](); + await promise; + + expect(mockInput.accept).toBe('image/*'); + }); + + it('should set multiple attribute when true', async () => { + const svc = setup(); + const promise = svc.pickFile(undefined, true); + mockInput.files = createFileList([]); + listeners['change'](); + await promise; + + expect(mockInput.multiple).toBe(true); + }); + + it('should not set multiple when false or undefined', async () => { + const svc = setup(); + const promise = svc.pickFile(); + mockInput.files = createFileList([]); + listeners['change'](); + await promise; + + expect(mockInput.multiple).toBe(false); + }); + + it('should return multiple files when multiple is true', async () => { + const svc = setup(); + const promise = svc.pickFile(undefined, true); + + const f1 = new File(['a'], 'a.txt', { type: 'text/plain' }); + const f2 = new File(['b'], 'b.txt', { type: 'text/plain' }); + mockInput.files = createFileList([f1, f2]); + listeners['change'](); + + const result = await promise; + expect(result).toEqual([f1, f2]); + }); + + it('should return empty array when cancelled', async () => { + const svc = setup(); + const promise = svc.pickFile(); + listeners['cancel'](); + + const result = await promise; + expect(result).toEqual([]); + }); + + it('should return empty array when files is null on change', async () => { + const svc = setup(); + const promise = svc.pickFile(); + mockInput.files = null; + listeners['change'](); + + const result = await promise; + expect(result).toEqual([]); + }); + + it('should cleanup input after selection', async () => { + const svc = setup(); + const promise = svc.pickFile(); + mockInput.files = createFileList([]); + listeners['change'](); + await promise; + + expect(mockInput.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + expect(mockInput.removeEventListener).toHaveBeenCalledWith('cancel', expect.any(Function)); + expect(mockInput.parentNode!.removeChild).toHaveBeenCalledWith(mockInput); + }); + + it('should cleanup input after cancel', async () => { + const svc = setup(); + const promise = svc.pickFile(); + listeners['cancel'](); + await promise; + + expect(mockInput.removeEventListener).toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------- + // pickImage + // ------------------------------------------------------------------- + it('should call pickFile with image/* and single mode', async () => { + const svc = setup(); + const promise = svc.pickImage(); + + expect(mockInput.accept).toBe('image/*'); + expect(mockInput.multiple).toBe(false); + + const img = new File(['pixels'], 'photo.png', { type: 'image/png' }); + mockInput.files = createFileList([img]); + listeners['change'](); + + const result = await promise; + expect(result).toEqual(img); + }); + + it('should return null from pickImage when cancelled', async () => { + const svc = setup(); + const promise = svc.pickImage(); + listeners['cancel'](); + + const result = await promise; + expect(result).toBeNull(); + }); +}); diff --git a/packages/core/src/lib/files/file-picker.service.ts b/packages/core/src/lib/files/file-picker.service.ts new file mode 100644 index 0000000..30892e8 --- /dev/null +++ b/packages/core/src/lib/files/file-picker.service.ts @@ -0,0 +1,70 @@ +import { Injectable, inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +/** + * Opens the browser's native file picker programmatically. + * + * Creates a hidden `` element, triggers a click, + * and resolves with the selected files. The input is removed after use. + * + * @example + * ```typescript + * const files = await picker.pickFile('application/pdf,.pdf', true); + * const image = await picker.pickImage(); + * ``` + */ +@Injectable() +export class FilePickerService { + private readonly doc = inject(DOCUMENT); + + /** + * Open the native file picker dialog. + * + * @param accept — MIME types or extensions (e.g. `'image/*'`, `'.pdf,.docx'`) + * @param multiple — Allow selecting multiple files. Default: `false`. + * @returns Selected files, or empty array if cancelled. + */ + pickFile(accept?: string, multiple?: boolean): Promise { + return new Promise(resolve => { + const input = this.doc.createElement('input') as HTMLInputElement; + input.type = 'file'; + if (accept) input.accept = accept; + if (multiple) input.multiple = true; + input.style.display = 'none'; + + const cleanup = () => { + input.removeEventListener('change', onChange); + input.removeEventListener('cancel', onCancel); + if (input.parentNode) input.parentNode.removeChild(input); + }; + + const onChange = () => { + const files = input.files ? Array.from(input.files) : []; + cleanup(); + resolve(files); + }; + + const onCancel = () => { + cleanup(); + resolve([]); + }; + + input.addEventListener('change', onChange); + input.addEventListener('cancel', onCancel); + + this.doc.body.appendChild(input); + input.click(); + }); + } + + /** + * Open the native file picker filtered to images only. + * + * Shorthand for `pickFile('image/*', false)`. + * @returns The selected image, or `null` if cancelled. + */ + async pickImage(): Promise { + const files = await this.pickFile('image/*', false); + return files[0] ?? null; + } +} From 9866daa46b525d042bf8abd39e0ebcfb632389d6 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Thu, 21 May 2026 09:07:57 +0200 Subject: [PATCH 04/13] [STEP-2.12-004] Add FileUploadService with HTTP progress tracking Upload files via POST with real-time progress events using HttpClient reportProgress. Includes upload() for single files, uploadMultiple() for concurrent uploads, and FILES_CONFIG endpoint fallback. --- .../src/lib/files/file-upload.service.spec.ts | 178 ++++++++++++++++++ .../core/src/lib/files/file-upload.service.ts | 94 +++++++++ 2 files changed, 272 insertions(+) create mode 100644 packages/core/src/lib/files/file-upload.service.spec.ts create mode 100644 packages/core/src/lib/files/file-upload.service.ts diff --git a/packages/core/src/lib/files/file-upload.service.spec.ts b/packages/core/src/lib/files/file-upload.service.spec.ts new file mode 100644 index 0000000..659fbfe --- /dev/null +++ b/packages/core/src/lib/files/file-upload.service.spec.ts @@ -0,0 +1,178 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClient, + provideHttpClient, +} from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { FileUploadService } from './file-upload.service'; +import { FILES_CONFIG, UploadProgress } from './file.types'; + +describe('FileUploadService', () => { + let svc: FileUploadService; + let httpMock: HttpTestingController; + + function setup(config?: object) { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + FileUploadService, + provideHttpClient(), + provideHttpClientTesting(), + ...(config ? [{ provide: FILES_CONFIG, useValue: config }] : []), + ], + }); + svc = TestBed.inject(FileUploadService); + httpMock = TestBed.inject(HttpTestingController); + } + + afterEach(() => { + httpMock?.verify(); + }); + + // ------------------------------------------------------------------- + // upload() basics + // ------------------------------------------------------------------- + it('should POST file as FormData to the given endpoint', () => { + setup(); + const file = new File(['data'], 'test.pdf', { type: 'application/pdf' }); + svc.upload(file, '/api/upload').subscribe(); + + const req = httpMock.expectOne('/api/upload'); + expect(req.request.method).toBe('POST'); + expect(req.request.body instanceof FormData).toBe(true); + req.flush({ id: '123' }); + }); + + it('should emit uploading status on Sent event', () => { + setup(); + const file = new File(['data'], 'test.pdf', { type: 'application/pdf' }); + const emissions: UploadProgress[] = []; + + svc.upload(file, '/api/upload').subscribe(p => emissions.push(p)); + + const req = httpMock.expectOne('/api/upload'); + req.flush({ id: '123' }); + + // Should have at least a complete emission + const last = emissions[emissions.length - 1]; + expect(last.status).toBe('complete'); + expect(last.percent).toBe(100); + }); + + it('should emit progress events with percentage', () => { + setup(); + const file = new File(['data'], 'test.pdf', { type: 'application/pdf' }); + const emissions: UploadProgress[] = []; + + svc.upload(file, '/api/upload').subscribe(p => emissions.push(p)); + + const req = httpMock.expectOne('/api/upload'); + + // Simulate progress events + req.event({ type: 0 }); // Sent + req.event({ type: 1, loaded: 50, total: 100 }); // UploadProgress + req.event({ type: 1, loaded: 100, total: 100 }); // UploadProgress + req.event({ type: 4, body: { ok: true }, status: 200, statusText: 'OK', headers: req.request.headers, url: '/api/upload' }); // Response + + // HttpClient may emit additional internal events; check key states + expect(emissions.length).toBeGreaterThanOrEqual(4); + + // Find key states in emissions + const uploading = emissions.filter(e => e.status === 'uploading'); + const complete = emissions.filter(e => e.status === 'complete'); + + expect(uploading.length).toBeGreaterThanOrEqual(2); + // First uploading event: Sent (0%) + expect(uploading[0].percent).toBe(0); + // Progress events + const progress50 = uploading.find(e => e.percent === 50); + expect(progress50).toBeDefined(); + expect(progress50!.loadedBytes).toBe(50); + expect(progress50!.totalBytes).toBe(100); + const progress100 = uploading.find(e => e.percent === 100); + expect(progress100).toBeDefined(); + // Complete + expect(complete.length).toBe(1); + expect(complete[0].percent).toBe(100); + expect(complete[0].response).toEqual({ ok: true }); + }); + + // ------------------------------------------------------------------- + // Headers + // ------------------------------------------------------------------- + it('should pass custom headers', () => { + setup(); + const file = new File(['data'], 'test.pdf', { type: 'application/pdf' }); + svc.upload(file, '/api/upload', { 'X-Custom': 'value' }).subscribe(); + + const req = httpMock.expectOne('/api/upload'); + expect(req.request.headers.get('X-Custom')).toBe('value'); + req.flush({}); + }); + + // ------------------------------------------------------------------- + // Error handling + // ------------------------------------------------------------------- + it('should throw when no endpoint is provided and no config', () => { + setup(); + const file = new File(['data'], 'test.pdf', { type: 'application/pdf' }); + expect(() => svc.upload(file, '')).toThrow(/No upload endpoint/); + }); + + it('should use FILES_CONFIG.uploadEndpoint as fallback', () => { + setup({ + maxFileSizeBytes: 10_000_000, + allowedMimeTypes: [], + maxConcurrentUploads: 5, + uploadEndpoint: '/api/config-upload', + enablePreview: true, + }); + const file = new File(['data'], 'test.pdf', { type: 'application/pdf' }); + svc.upload(file, '').subscribe(); + + const req = httpMock.expectOne('/api/config-upload'); + expect(req.request.method).toBe('POST'); + req.flush({}); + }); + + it('should propagate HTTP errors to subscriber', () => { + setup(); + const file = new File(['data'], 'test.pdf', { type: 'application/pdf' }); + let error: any; + + svc.upload(file, '/api/upload').subscribe({ + error: e => (error = e), + }); + + const req = httpMock.expectOne('/api/upload'); + req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' }); + + expect(error).toBeDefined(); + expect(error.status).toBe(500); + }); + + // ------------------------------------------------------------------- + // uploadMultiple() + // ------------------------------------------------------------------- + it('should upload multiple files and return array of results', () => { + setup(); + const f1 = new File(['a'], 'a.txt', { type: 'text/plain' }); + const f2 = new File(['b'], 'b.txt', { type: 'text/plain' }); + let result: UploadProgress[] | undefined; + + svc.uploadMultiple([f1, f2], '/api/upload').subscribe(r => (result = r)); + + const reqs = httpMock.match('/api/upload'); + expect(reqs.length).toBe(2); + reqs[0].flush({ id: '1' }); + reqs[1].flush({ id: '2' }); + + expect(result).toBeDefined(); + expect(result!.length).toBe(2); + expect(result![0].status).toBe('complete'); + expect(result![1].status).toBe('complete'); + }); +}); diff --git a/packages/core/src/lib/files/file-upload.service.ts b/packages/core/src/lib/files/file-upload.service.ts new file mode 100644 index 0000000..a8f3f82 --- /dev/null +++ b/packages/core/src/lib/files/file-upload.service.ts @@ -0,0 +1,94 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpEventType, HttpHeaders } from '@angular/common/http'; +import { Observable, map, scan, forkJoin } from 'rxjs'; +import { FILES_CONFIG, UploadProgress } from './file.types'; + +/** + * Uploads files via HTTP POST with real-time progress tracking. + * + * Uses `HttpClient` with `reportProgress` to emit `UploadProgress` events + * containing percentage, loaded/total bytes, and final response. + * + * @example + * ```typescript + * const uploader = inject(FileUploadService); + * uploader.upload(file, '/api/files').subscribe(p => { + * console.log(`${p.percent}% — ${p.status}`); + * }); + * ``` + */ +@Injectable() +export class FileUploadService { + private readonly http = inject(HttpClient); + private readonly config = inject(FILES_CONFIG, { optional: true }); + + /** + * Upload a single file with progress tracking. + * + * @param file — The file to upload. + * @param endpoint — URL to POST to. If not provided, falls back to `FilesConfig.uploadEndpoint`. + * @param headers — Optional extra headers (e.g. authorization, content-disposition). + * @returns Observable that emits `UploadProgress` on each progress event and completes on success. + */ + upload(file: File, endpoint: string, headers?: Record): Observable { + const url = endpoint || this.config?.uploadEndpoint; + if (!url) { + throw new Error('FileUploadService: No upload endpoint provided. Pass an endpoint or configure uploadEndpoint in provideFiles().'); + } + + const formData = new FormData(); + formData.append('file', file, file.name); + + let httpHeaders: HttpHeaders | undefined; + if (headers) { + httpHeaders = new HttpHeaders(headers); + } + + return this.http.post(url, formData, { + reportProgress: true, + observe: 'events', + headers: httpHeaders, + }).pipe( + scan((acc, event) => { + switch (event.type) { + case HttpEventType.Sent: + return { percent: 0, loadedBytes: 0, status: 'uploading' }; + + case HttpEventType.UploadProgress: { + const total = event.total; + const loaded = event.loaded ?? 0; + const percent = total ? Math.round((loaded / total) * 100) : acc.percent; + return { + percent, + loadedBytes: loaded, + totalBytes: total, + status: 'uploading' as const, + }; + } + + case HttpEventType.Response: + return { + percent: 100, + loadedBytes: acc.loadedBytes, + totalBytes: acc.totalBytes, + status: 'complete' as const, + response: event.body, + }; + + default: + return acc; + } + }, { percent: 0, loadedBytes: 0, status: 'pending' }), + ); + } + + /** + * Upload multiple files concurrently with individual progress tracking. + * + * @returns Observable that emits an array of `UploadProgress` (one per file) when all complete. + */ + uploadMultiple(files: File[], endpoint: string): Observable { + const uploads = files.map(file => this.upload(file, endpoint)); + return forkJoin(uploads); + } +} From 8c0a371f33ed1a7a64448a5e9a8100e48cf95b56 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Thu, 21 May 2026 09:11:23 +0200 Subject: [PATCH 05/13] [STEP-2.12-005] Add FileDownloadService with URL and blob download support Trigger file downloads programmatically using hidden anchor + click trick. Includes downloadBlob() with automatic blob URL revocation to prevent memory leaks. SSR-safe via inject(DOCUMENT). --- .../lib/files/file-download.service.spec.ts | 125 ++++++++++++++++++ .../src/lib/files/file-download.service.ts | 61 +++++++++ 2 files changed, 186 insertions(+) create mode 100644 packages/core/src/lib/files/file-download.service.spec.ts create mode 100644 packages/core/src/lib/files/file-download.service.ts diff --git a/packages/core/src/lib/files/file-download.service.spec.ts b/packages/core/src/lib/files/file-download.service.spec.ts new file mode 100644 index 0000000..b778fd0 --- /dev/null +++ b/packages/core/src/lib/files/file-download.service.spec.ts @@ -0,0 +1,125 @@ +import { TestBed } from '@angular/core/testing'; +import { DOCUMENT } from '@angular/common'; +import { FileDownloadService } from './file-download.service'; + +describe('FileDownloadService', () => { + let svc: FileDownloadService; + let mockAnchor: { + href: string; + download: string; + style: { display: string }; + click: ReturnType; + }; + let mockDoc: { + createElement: ReturnType; + body: { appendChild: ReturnType; removeChild: ReturnType }; + }; + + beforeEach(() => { + mockAnchor = { + href: '', + download: '', + style: { display: '' }, + click: vi.fn(), + }; + + mockDoc = { + createElement: vi.fn().mockReturnValue(mockAnchor), + body: { + appendChild: vi.fn(), + removeChild: vi.fn(), + }, + }; + + TestBed.configureTestingModule({ + providers: [ + FileDownloadService, + { provide: DOCUMENT, useValue: mockDoc }, + ], + }); + + svc = TestBed.inject(FileDownloadService); + }); + + // ------------------------------------------------------------------- + // download() + // ------------------------------------------------------------------- + describe('download()', () => { + it('should create an anchor element', () => { + svc.download('/api/file/1', 'report.pdf'); + expect(mockDoc.createElement).toHaveBeenCalledWith('a'); + }); + + it('should set href and download attributes', () => { + svc.download('/api/file/1', 'report.pdf'); + expect(mockAnchor.href).toBe('/api/file/1'); + expect(mockAnchor.download).toBe('report.pdf'); + }); + + it('should hide the anchor element', () => { + svc.download('/api/file/1', 'report.pdf'); + expect(mockAnchor.style.display).toBe('none'); + }); + + it('should append anchor to body, click it, and remove it', () => { + svc.download('/api/file/1', 'report.pdf'); + + expect(mockDoc.body.appendChild).toHaveBeenCalledWith(mockAnchor); + expect(mockAnchor.click).toHaveBeenCalled(); + expect(mockDoc.body.removeChild).toHaveBeenCalledWith(mockAnchor); + + // Verify order: append -> click -> remove + const appendOrder = mockDoc.body.appendChild.mock.invocationCallOrder[0]; + const clickOrder = mockAnchor.click.mock.invocationCallOrder[0]; + const removeOrder = mockDoc.body.removeChild.mock.invocationCallOrder[0]; + expect(appendOrder).toBeLessThan(clickOrder); + expect(clickOrder).toBeLessThan(removeOrder); + }); + }); + + // ------------------------------------------------------------------- + // downloadBlob() + // ------------------------------------------------------------------- + describe('downloadBlob()', () => { + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; + + beforeEach(() => { + createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + }); + + afterEach(() => { + createObjectURLSpy.mockRestore(); + revokeObjectURLSpy.mockRestore(); + }); + + it('should create a blob URL from the blob', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + svc.downloadBlob(blob, 'test.txt'); + expect(createObjectURLSpy).toHaveBeenCalledWith(blob); + }); + + it('should download using the blob URL', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + svc.downloadBlob(blob, 'test.txt'); + expect(mockAnchor.href).toBe('blob:mock-url'); + expect(mockAnchor.download).toBe('test.txt'); + }); + + it('should revoke the blob URL after download', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + svc.downloadBlob(blob, 'test.txt'); + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('should revoke after clicking (correct order)', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + svc.downloadBlob(blob, 'test.txt'); + + const clickOrder = mockAnchor.click.mock.invocationCallOrder[0]; + const revokeOrder = revokeObjectURLSpy.mock.invocationCallOrder[0]; + expect(clickOrder).toBeLessThan(revokeOrder); + }); + }); +}); diff --git a/packages/core/src/lib/files/file-download.service.ts b/packages/core/src/lib/files/file-download.service.ts new file mode 100644 index 0000000..0936506 --- /dev/null +++ b/packages/core/src/lib/files/file-download.service.ts @@ -0,0 +1,61 @@ +import { Injectable, inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +/** + * Triggers file downloads programmatically. + * + * Uses the "hidden anchor + click" trick to initiate browser downloads + * from URLs or in-memory Blobs. Blob URLs are revoked after download + * to prevent memory leaks. + * + * @example + * ```typescript + * const dl = inject(FileDownloadService); + * + * // Download from URL + * dl.download('/api/files/123', 'report.pdf'); + * + * // Download in-memory blob + * const blob = new Blob(['hello'], { type: 'text/plain' }); + * dl.downloadBlob(blob, 'greeting.txt'); + * ``` + */ +@Injectable() +export class FileDownloadService { + private readonly doc = inject(DOCUMENT); + + /** + * Download a file from a URL. + * + * Creates a hidden `` element with the download attribute, + * clicks it, and removes it from the DOM. + * + * @param url — URL to download from. + * @param filename — Suggested filename for the download. + */ + download(url: string, filename: string): void { + const anchor = this.doc.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.style.display = 'none'; + + this.doc.body.appendChild(anchor); + anchor.click(); + this.doc.body.removeChild(anchor); + } + + /** + * Download an in-memory Blob as a file. + * + * Creates a temporary blob URL, triggers the download, then + * revokes the URL to free memory. + * + * @param blob — The Blob or File to download. + * @param filename — Suggested filename for the download. + */ + downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + this.download(url, filename); + URL.revokeObjectURL(url); + } +} From aa233c47b92df0189eb12f554c46562d5610c521 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Thu, 21 May 2026 09:14:36 +0200 Subject: [PATCH 06/13] [STEP-2.12-006] Add FilePreviewService with blob URL lifecycle management Preview files in new browser tabs via window.open with blob URLs. Tracks active URLs for bulk cleanup via revokeAll(). Respects FILES_CONFIG.enablePreview setting. SSR-safe via inject(DOCUMENT). --- .../lib/files/file-preview.service.spec.ts | 176 ++++++++++++++++++ .../src/lib/files/file-preview.service.ts | 93 +++++++++ 2 files changed, 269 insertions(+) create mode 100644 packages/core/src/lib/files/file-preview.service.spec.ts create mode 100644 packages/core/src/lib/files/file-preview.service.ts diff --git a/packages/core/src/lib/files/file-preview.service.spec.ts b/packages/core/src/lib/files/file-preview.service.spec.ts new file mode 100644 index 0000000..40b984a --- /dev/null +++ b/packages/core/src/lib/files/file-preview.service.spec.ts @@ -0,0 +1,176 @@ +import { TestBed } from '@angular/core/testing'; +import { DOCUMENT } from '@angular/common'; +import { FilePreviewService } from './file-preview.service'; +import { FILES_CONFIG } from './file.types'; + +describe('FilePreviewService', () => { + let svc: FilePreviewService; + let mockWindow: { open: ReturnType }; + let mockDoc: { defaultView: any }; + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; + let urlCounter: number; + + function setup(config?: object) { + TestBed.resetTestingModule(); + + mockWindow = { open: vi.fn() }; + mockDoc = { defaultView: mockWindow }; + + TestBed.configureTestingModule({ + providers: [ + FilePreviewService, + { provide: DOCUMENT, useValue: mockDoc }, + ...(config ? [{ provide: FILES_CONFIG, useValue: config }] : []), + ], + }); + + svc = TestBed.inject(FilePreviewService); + } + + beforeEach(() => { + urlCounter = 0; + createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => { + return `blob:mock-url-${++urlCounter}`; + }); + revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + }); + + afterEach(() => { + createObjectURLSpy.mockRestore(); + revokeObjectURLSpy.mockRestore(); + }); + + // ------------------------------------------------------------------- + // createBlobUrl() + // ------------------------------------------------------------------- + describe('createBlobUrl()', () => { + it('should create a blob URL from a blob', () => { + setup(); + const blob = new Blob(['test'], { type: 'text/plain' }); + const url = svc.createBlobUrl(blob); + + expect(createObjectURLSpy).toHaveBeenCalledWith(blob); + expect(url).toBe('blob:mock-url-1'); + }); + + it('should track the created URL', () => { + setup(); + const blob = new Blob(['test'], { type: 'text/plain' }); + svc.createBlobUrl(blob); + + expect(svc.activeUrlCount).toBe(1); + }); + }); + + // ------------------------------------------------------------------- + // revokeBlobUrl() + // ------------------------------------------------------------------- + describe('revokeBlobUrl()', () => { + it('should revoke a blob URL', () => { + setup(); + const blob = new Blob(['test'], { type: 'text/plain' }); + const url = svc.createBlobUrl(blob); + svc.revokeBlobUrl(url); + + expect(revokeObjectURLSpy).toHaveBeenCalledWith(url); + }); + + it('should remove the URL from tracking', () => { + setup(); + const blob = new Blob(['test'], { type: 'text/plain' }); + const url = svc.createBlobUrl(blob); + svc.revokeBlobUrl(url); + + expect(svc.activeUrlCount).toBe(0); + }); + }); + + // ------------------------------------------------------------------- + // revokeAll() + // ------------------------------------------------------------------- + describe('revokeAll()', () => { + it('should revoke all tracked URLs', () => { + setup(); + const b1 = new Blob(['a'], { type: 'text/plain' }); + const b2 = new Blob(['b'], { type: 'text/plain' }); + svc.createBlobUrl(b1); + svc.createBlobUrl(b2); + + svc.revokeAll(); + + expect(revokeObjectURLSpy).toHaveBeenCalledTimes(2); + expect(svc.activeUrlCount).toBe(0); + }); + }); + + // ------------------------------------------------------------------- + // previewInNewTab() + // ------------------------------------------------------------------- + describe('previewInNewTab()', () => { + it('should create a blob URL and open it in a new tab', () => { + setup(); + const blob = new Blob(['pdf content'], { type: 'application/pdf' }); + const url = svc.previewInNewTab(blob); + + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(mockWindow.open).toHaveBeenCalledWith(url, '_blank'); + expect(url).toBe('blob:mock-url-1'); + }); + + it('should track the URL for cleanup', () => { + setup(); + const blob = new Blob(['content'], { type: 'text/plain' }); + svc.previewInNewTab(blob); + + expect(svc.activeUrlCount).toBe(1); + }); + + it('should create a new blob with override MIME type when provided', () => { + setup(); + const blob = new Blob(['content'], { type: 'application/octet-stream' }); + svc.previewInNewTab(blob, 'application/pdf'); + + // createObjectURL is called with a new Blob (with overridden type) + const calledBlob = createObjectURLSpy.mock.calls[0][0] as Blob; + expect(calledBlob.type).toBe('application/pdf'); + }); + + it('should return null when enablePreview is false', () => { + setup({ + maxFileSizeBytes: 10_000_000, + allowedMimeTypes: [], + maxConcurrentUploads: 5, + enablePreview: false, + }); + const blob = new Blob(['content'], { type: 'text/plain' }); + const result = svc.previewInNewTab(blob); + + expect(result).toBeNull(); + expect(mockWindow.open).not.toHaveBeenCalled(); + }); + + it('should work when enablePreview is true', () => { + setup({ + maxFileSizeBytes: 10_000_000, + allowedMimeTypes: [], + maxConcurrentUploads: 5, + enablePreview: true, + }); + const blob = new Blob(['content'], { type: 'text/plain' }); + const url = svc.previewInNewTab(blob); + + expect(url).not.toBeNull(); + expect(mockWindow.open).toHaveBeenCalled(); + }); + + it('should work without FILES_CONFIG (preview enabled by default)', () => { + setup(); // no config + const blob = new Blob(['content'], { type: 'text/plain' }); + const url = svc.previewInNewTab(blob); + + expect(url).not.toBeNull(); + expect(mockWindow.open).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/lib/files/file-preview.service.ts b/packages/core/src/lib/files/file-preview.service.ts new file mode 100644 index 0000000..8a1d4cf --- /dev/null +++ b/packages/core/src/lib/files/file-preview.service.ts @@ -0,0 +1,93 @@ +import { Injectable, inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { FILES_CONFIG } from './file.types'; + +/** + * Manages file previews and blob URL lifecycle. + * + * Creates temporary blob URLs for displaying file content and opens + * previews in new browser tabs. Tracks active blob URLs and provides + * cleanup to prevent memory leaks. + * + * @example + * ```typescript + * const preview = inject(FilePreviewService); + * + * // Preview a blob in a new tab + * preview.previewInNewTab(blob, 'application/pdf'); + * + * // Manual blob URL management + * const url = preview.createBlobUrl(blob); + * // ... use url in an or