From 7bd962f18c6bbac50fa1b1b197b6fcdae48bb450 Mon Sep 17 00:00:00 2001 From: Mane087 Date: Mon, 13 Apr 2026 18:17:19 -0600 Subject: [PATCH] feat: archivo con nombre personalizado #2 --- AGENTS.md | 190 ++++++++++++++++++ src/app/app.component.html | 7 + src/app/app.component.ts | 65 +++++- .../modal-save-file.component.html | 41 ++++ .../modal-save-file.component.ts | 49 +++++ src/app/config/markdown-file.ts | 6 + src/app/utils/types/save-file.ts | 19 ++ tests/app/app.component.spec.ts | 146 ++++++++++++-- .../modal-save-file.component.spec.ts | 63 ++++++ tests/app/services/shortcuts.service.spec.ts | 3 +- 10 files changed, 571 insertions(+), 18 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/app/components/modal-save-file/modal-save-file.component.html create mode 100644 src/app/components/modal-save-file/modal-save-file.component.ts create mode 100644 src/app/config/markdown-file.ts create mode 100644 src/app/utils/types/save-file.ts create mode 100644 tests/app/components/modal-save-file.component.spec.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5a5cc03 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,190 @@ +# Overview + +Este proyecto es una aplicación **Angular 20 standalone** orientada a la edición de Markdown con vista previa en vivo. La app se inicializa con `bootstrapApplication` y tiene a `AppComponent` como punto central de composición. El estado local se gestiona principalmente con **signals** y **computed**, evitando complejidad innecesaria cuando no hace falta un store global. + +La aplicación ofrece un flujo de edición centrado en productividad: renderizado Markdown en tiempo real, sanitización del HTML generado, resaltado de bloques de código, extensiones personalizadas para alertas, modales de inserción de contenido, búsqueda dentro del editor, atajos de teclado globales y soporte de importación/exportación de archivos `.md`. + +--- + +# Estructura del proyecto + +Estructura relevante del código: + +- `src/app/components` + Componentes reutilizables y piezas principales de UI, incluyendo editor, preview, modales y controles asociados. + +- `src/app/layouts` + Layouts y composición estructural de pantallas o contenedores visuales. + +- `src/app/services` + Servicios compartidos, por ejemplo para atajos de teclado u otra lógica transversal que no debe quedar dispersa en componentes. + +- `src/app/config` + Configuración reutilizable de la app, integraciones y constantes de comportamiento. + +- `src/app/utils/data` + Datos utilitarios y estructuras estáticas compartidas. + +- `src/app/utils/types` + Tipos de TypeScript reutilizables para mantener contratos claros y evitar tipado implícito o duplicado. + +- `public/` + Assets públicos estáticos. + +- `tests/` + Pruebas unitarias/integración con **Jest**. + +- `e2e/` + Pruebas end-to-end con **Playwright**. + +--- + +# Dependencias clave + +## Runtime y UI + +- **Angular 20**: base del proyecto, arquitectura standalone y composición principal. +- **marked**: parser/renderizador de Markdown. +- **DOMPurify**: sanitización del HTML generado antes de mostrarlo en preview. +- **highlight.js**: resaltado sintáctico para bloques de código. +- **Tailwind CSS v4**: estilos utilitarios y soporte del tema visual. + +## Calidad y testing + +- **Jest**: pruebas unitarias. +- **Playwright**: pruebas end-to-end. +- **ESLint**: reglas de calidad y consistencia. +- **Prettier**: formato de código. +- **Husky**: automatización de hooks de git. +- **commitlint**: validación del formato de commits. + +--- + +# Principales funcionalidades + +- **Editor Markdown con preview en vivo** + La edición actualiza la representación renderizada de forma inmediata. + +- **Render de Markdown con `marked`** + El contenido se transforma a HTML usando `marked`. + +- **Sanitización con `DOMPurify`** + Todo HTML destinado al preview debe pasar por sanitización antes de insertarse en la UI. + +- **Resaltado de código con `highlight.js`** + Los bloques de código renderizados se mejoran visualmente con highlight sintáctico. + +- **Extensiones custom para alerts** + El parser/render del Markdown incorpora extensiones específicas para bloques tipo alerta. + +- **Modales de inserción** + Existen modales dedicados para insertar o configurar: + - Link + - Image + - Block Code + - Table + +- **Servicio global de atajos de teclado** + Los atajos no deben implementarse de forma aislada en cada componente si su comportamiento es transversal. + +- **Búsqueda dentro del editor** + El editor incorpora capacidades de búsqueda sobre el contenido. + +- **Importación y exportación de `.md`** + El usuario puede cargar y descargar contenido Markdown. + +- **Tema oscuro en editor y preview** + La experiencia visual contempla modo oscuro como comportamiento relevante del producto. + +--- + +# Ejecución + +## Scripts principales + +- `npm run start` + Levanta la aplicación en desarrollo. + +- `npm run build` + Genera la build de producción. + +- `npm run build:gh` + Genera la build adaptada al flujo de despliegue para GitHub Pages. + +- `npm run watch` + Ejecuta compilación en modo observación. + +## Calidad + +- `npm run lint` + Ejecuta ESLint. +- `npm run lint:fix` + Intenta corregir automáticamente problemas de lint/formato. + +## Testing unitario + +- `npm run test` + Ejecuta la suite con Jest. +- `npm run test:watch` + Ejecuta Jest en modo watch. +- `npm run test:coverage` + Genera cobertura de pruebas. + +## Testing end-to-end + +- `npm run e2e` + Ejecuta Playwright. +- `npm run e2e:ui` + Abre Playwright en modo UI. +- `npm run e2e:debug` + Ejecuta pruebas e2e con depuración. +- `npm run e2e:report` + Muestra el reporte de Playwright. + +--- + +# Reglas + +## Arquitectura y organización + +- Usar **standalone components** como enfoque por defecto. +- Mantener `AppComponent` como punto central de composición de la app. +- Evitar dispersar lógica reusable en componentes; mover comportamiento compartido a: + - `services` + - `config` + - `utils/data` + - `utils/types` + +## Estado y reactividad + +- **Preferir `signals` y `computed` para estado local**. +- Evitar introducir soluciones de estado más pesadas si el caso puede resolverse localmente y de forma clara. + +## Estilo Angular/TypeScript + +- Usar `inject()` de forma consistente en lugar de patrones más antiguos cuando aplique. +- Mantener **type imports** consistentes. +- **No usar `any`** salvo justificación excepcional y explícita. +- Priorizar tipado claro, pequeño y reutilizable. + +## Seguridad y renderizado + +- Todo HTML generado desde Markdown debe considerarse no confiable hasta pasar por **DOMPurify**. +- No saltarse la sanitización en flujos de preview o render intermedio. + +## Calidad + +- **ESLint y Prettier son obligatorios** antes de integrar cambios. +- Mantener el código alineado con las reglas ya establecidas por el repositorio. +- No introducir patrones incompatibles con la arquitectura standalone actual. + +## Testing + +- Las **pruebas unitarias** viven en `tests/`. +- Las **pruebas e2e** viven en `e2e/`. +- Los cambios relevantes en lógica, UI crítica o flujos principales deben venir acompañados por pruebas adecuadas. + +## Alcance de cambios + +- Favorecer cambios pequeños, coherentes y localizados. +- Si una lógica empieza a reutilizarse o crecer, extraerla tempranamente a una abstracción compartida en lugar de duplicarla. diff --git a/src/app/app.component.html b/src/app/app.component.html index 41488cc..8291927 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -246,6 +246,13 @@ (hideOrShowModal)="openModal($event, '')" /> } + @case ('Save File') { + + } } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index c4759b6..18e9f84 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -25,6 +25,7 @@ import { listIconsOthers } from './utils/data/list-other-options'; import { ModalUrlComponent } from './components/modal-url/modal-url.component'; import { ModalCodeComponent } from './components/modal-code/modal-code.component'; import { ModalImageComponent } from './components/modal-image/modal-image.component'; +import { ModalSaveFileComponent } from './components/modal-save-file/modal-save-file.component'; import { ModalTableComponent } from './components/modal-table/modal-table.component'; import { ShortcutsService } from './services/shortcuts.service'; import { ModalComponent } from './layouts/modal/modal.component'; @@ -33,6 +34,8 @@ import { codeExtension } from './config/marked-code'; import { SelectComponent } from './components/select/select.component'; import type { Options } from './utils/types/option'; import { listAlerts } from './utils/data/list-alerts'; +import { MARKDOWN_FILE } from './config/markdown-file'; +import type { SaveFilePickerWindow } from './utils/types/save-file'; marked.setOptions({ gfm: true, @@ -51,6 +54,7 @@ marked.use({ extensions: [alertExtension] }); ModalUrlComponent, ModalImageComponent, ModalCodeComponent, + ModalSaveFileComponent, ModalTableComponent, ModalComponent, SelectComponent, @@ -68,6 +72,7 @@ export class AppComponent { typeOfModal = signal(''); searchQuery = signal(''); lastMatchIndex = signal(0); + suggestedMarkdownFileName = signal(MARKDOWN_FILE.defaultName); listIconsText: AsideElement[] = listIconsText; listIconsOthers: AsideElement[] = listIconsOthers; @@ -337,16 +342,66 @@ export class AppComponent { } } - downloadMarkdown() { - const content = this.inputValue(); - const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); + private createMarkdownBlob(): Blob { + return new Blob([this.inputValue()], { type: `${MARKDOWN_FILE.mimeType};charset=utf-8` }); + } + private triggerBrowserDownload(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = 'document.md'; + a.download = fileName; a.click(); - URL.revokeObjectURL(url); } + + private isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError'; + } + + private openSaveFileModal(defaultFileName: string = MARKDOWN_FILE.defaultName) { + this.suggestedMarkdownFileName.set(defaultFileName); + this.openModal(true, 'Save File'); + } + + saveMarkdownWithCustomName(fileName: string) { + const blob = this.createMarkdownBlob(); + this.triggerBrowserDownload(blob, fileName); + this.openModal(false, ''); + } + + async downloadMarkdown() { + const blob = this.createMarkdownBlob(); + const pickerWindow = window as SaveFilePickerWindow; + + if (!pickerWindow.showSaveFilePicker) { + this.openSaveFileModal(); + return; + } + + try { + const handle = await pickerWindow.showSaveFilePicker({ + suggestedName: MARKDOWN_FILE.defaultName, + excludeAcceptAllOption: true, + types: [ + { + description: MARKDOWN_FILE.description, + accept: { + [MARKDOWN_FILE.mimeType]: [MARKDOWN_FILE.extension], + }, + }, + ], + }); + + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + } catch (error: unknown) { + if (this.isAbortError(error)) { + return; + } + + this.openSaveFileModal(); + } + } } diff --git a/src/app/components/modal-save-file/modal-save-file.component.html b/src/app/components/modal-save-file/modal-save-file.component.html new file mode 100644 index 0000000..e2e2ff8 --- /dev/null +++ b/src/app/components/modal-save-file/modal-save-file.component.html @@ -0,0 +1,41 @@ +
+

Guardar archivo

+
+ +
+
+ + +
+
+ + diff --git a/src/app/components/modal-save-file/modal-save-file.component.ts b/src/app/components/modal-save-file/modal-save-file.component.ts new file mode 100644 index 0000000..e542636 --- /dev/null +++ b/src/app/components/modal-save-file/modal-save-file.component.ts @@ -0,0 +1,49 @@ +import { Component, effect, input, output, signal } from '@angular/core'; + +import { MARKDOWN_FILE } from '../../config/markdown-file'; + +@Component({ + selector: 'app-modal-save-file', + imports: [], + templateUrl: './modal-save-file.component.html', +}) +export class ModalSaveFileComponent { + readonly initialFileName = input(MARKDOWN_FILE.defaultName); + readonly hideOrShowModal = output(); + readonly saveFileName = output(); + + readonly fileName = signal(MARKDOWN_FILE.defaultName); + + constructor() { + effect(() => { + this.fileName.set(this.initialFileName()); + }); + } + + onInput(event: Event) { + const input = event.target as HTMLInputElement; + this.fileName.set(input.value); + } + + closeModal() { + this.hideOrShowModal.emit(false); + } + + saveModal() { + const normalizedName = this.normalizeFileName(this.fileName()); + this.saveFileName.emit(normalizedName); + this.hideOrShowModal.emit(false); + } + + private normalizeFileName(fileName: string): string { + const trimmedName = fileName.trim(); + + if (!trimmedName) { + return MARKDOWN_FILE.defaultName; + } + + return trimmedName.toLowerCase().endsWith(MARKDOWN_FILE.extension) + ? trimmedName + : `${trimmedName}${MARKDOWN_FILE.extension}`; + } +} diff --git a/src/app/config/markdown-file.ts b/src/app/config/markdown-file.ts new file mode 100644 index 0000000..5a7eb62 --- /dev/null +++ b/src/app/config/markdown-file.ts @@ -0,0 +1,6 @@ +export const MARKDOWN_FILE = { + mimeType: 'text/markdown', + extension: '.md', + defaultName: 'document.md', + description: 'Markdown files', +}; diff --git a/src/app/utils/types/save-file.ts b/src/app/utils/types/save-file.ts new file mode 100644 index 0000000..27cdf7a --- /dev/null +++ b/src/app/utils/types/save-file.ts @@ -0,0 +1,19 @@ +interface SaveFilePickerWritable { + write(data: Blob): Promise; + close(): Promise; +} + +interface SaveFilePickerHandle { + createWritable(): Promise; +} + +export interface SaveFilePickerWindow extends Window { + showSaveFilePicker?: (options: { + suggestedName: string; + excludeAcceptAllOption?: boolean; + types: { + description: string; + accept: Record; + }[]; + }) => Promise; +} diff --git a/tests/app/app.component.spec.ts b/tests/app/app.component.spec.ts index 7ef2795..5e5050e 100644 --- a/tests/app/app.component.spec.ts +++ b/tests/app/app.component.spec.ts @@ -1,14 +1,18 @@ import type { ComponentFixture } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing'; + import { AppComponent } from '../../src/app/app.component'; -import { ShortcutsService } from '../../src/app/utils/services/shortcuts.service'; -import { DomSanitizer } from '@angular/platform-browser'; +import { ShortcutsService } from '../../src/app/services/shortcuts.service'; describe('AppComponent', () => { let component: AppComponent; let fixture: ComponentFixture; let shortcutsService: jest.Mocked; + afterEach(() => { + jest.restoreAllMocks(); + }); + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [AppComponent], @@ -20,12 +24,6 @@ describe('AppComponent', () => { handle: jest.fn().mockReturnValue(false), }, }, - { - provide: DomSanitizer, - useValue: { - bypassSecurityTrustHtml: jest.fn((v) => v), - }, - }, ], }).compileComponents(); @@ -55,7 +53,7 @@ describe('AppComponent', () => { component.addElement('', 'between'); - expect(component.inputValue()).toBe('hello\n'); + expect(component.inputValue()).toBe('hello'); }); it('should wrap selected text when insert is between', () => { @@ -150,7 +148,7 @@ describe('AppComponent', () => { } as unknown as Event; component.onHeadingChange(event); - expect(component.inputValue()).toBe('hello\n###'); + expect(component.inputValue()).toBe('### hello'); }); it('should show modal and insert type of modal', () => { @@ -173,7 +171,131 @@ describe('AppComponent', () => { const value = '[Google](https://google.com)'; - component.addContentFromModal(value); - expect(component.inputValue()).toBe('hello\n[Google](https://google.com)'); + component.addElement(value, 'start'); + expect(component.inputValue()).toBe('[Google](https://google.com) hello'); + }); + + it('should open save picker and write markdown file when supported', async () => { + component.inputValue.set('# Hello'); + + const write = jest.fn().mockResolvedValue(undefined); + const close = jest.fn().mockResolvedValue(undefined); + const createWritable = jest.fn().mockResolvedValue({ write, close }); + const showSaveFilePicker = jest.fn().mockResolvedValue({ createWritable }); + + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + writable: true, + value: showSaveFilePicker, + }); + + await component.downloadMarkdown(); + + expect(showSaveFilePicker).toHaveBeenCalledWith({ + suggestedName: 'document.md', + excludeAcceptAllOption: true, + types: [ + { + description: 'Markdown files', + accept: { + 'text/markdown': ['.md'], + }, + }, + ], + }); + expect(createWritable).toHaveBeenCalled(); + expect(write).toHaveBeenCalledTimes(1); + expect(write.mock.calls[0][0]).toBeTruthy(); + expect(close).toHaveBeenCalled(); + }); + + it('should fallback to browser download when save picker is unavailable', async () => { + component.inputValue.set('# Hello'); + + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + writable: true, + value: undefined, + }); + + await component.downloadMarkdown(); + + expect(component.showModal()).toBe(true); + expect(component.typeOfModal()).toBe('Save File'); + expect(component.suggestedMarkdownFileName()).toBe('document.md'); + }); + + it('should not trigger fallback when user cancels save picker', async () => { + component.inputValue.set('# Hello'); + + const showSaveFilePicker = jest + .fn() + .mockRejectedValue(new DOMException('The user aborted a request.', 'AbortError')); + + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + writable: true, + value: showSaveFilePicker, + }); + + const createObjectUrl = jest.fn().mockReturnValue('blob:test'); + + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + writable: true, + value: createObjectUrl, + }); + + await component.downloadMarkdown(); + + expect(showSaveFilePicker).toHaveBeenCalled(); + expect(createObjectUrl).not.toHaveBeenCalled(); + }); + + it('should open custom save modal when save picker fails', async () => { + const showSaveFilePicker = jest + .fn() + .mockRejectedValue(new DOMException('Picker unavailable', 'SecurityError')); + + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + writable: true, + value: showSaveFilePicker, + }); + + await component.downloadMarkdown(); + + expect(component.showModal()).toBe(true); + expect(component.typeOfModal()).toBe('Save File'); + }); + + it('should download markdown with custom filename', () => { + component.inputValue.set('# Hello'); + + const click = jest.fn(); + const anchor = { href: '', download: '', click } as unknown as HTMLAnchorElement; + const createElementSpy = jest.spyOn(document, 'createElement').mockReturnValue(anchor); + const createObjectUrl = jest.fn().mockReturnValue('blob:test'); + const revokeObjectUrl = jest.fn(); + + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + writable: true, + value: createObjectUrl, + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + writable: true, + value: revokeObjectUrl, + }); + + component.saveMarkdownWithCustomName('notes.md'); + + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(anchor.href).toBe('blob:test'); + expect(anchor.download).toBe('notes.md'); + expect(click).toHaveBeenCalled(); + expect(createObjectUrl).toHaveBeenCalled(); + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:test'); }); }); diff --git a/tests/app/components/modal-save-file.component.spec.ts b/tests/app/components/modal-save-file.component.spec.ts new file mode 100644 index 0000000..196c9c1 --- /dev/null +++ b/tests/app/components/modal-save-file.component.spec.ts @@ -0,0 +1,63 @@ +import type { ComponentFixture } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ModalSaveFileComponent } from '../../../src/app/components/modal-save-file/modal-save-file.component'; + +describe('ModalSaveFileComponent', () => { + let component: ModalSaveFileComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ModalSaveFileComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ModalSaveFileComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('initialFileName', 'draft.md'); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize the file name from input', () => { + expect(component.fileName()).toBe('draft.md'); + }); + + it('should update fileName on input', () => { + const event = { + target: { value: 'meeting-notes' }, + } as unknown as Event; + + component.onInput(event); + + expect(component.fileName()).toBe('meeting-notes'); + }); + + it('should emit normalized file name and close modal on save', () => { + const saveSpy = jest.fn(); + const closeSpy = jest.fn(); + + component.saveFileName.subscribe(saveSpy); + component.hideOrShowModal.subscribe(closeSpy); + component.fileName.set('meeting-notes'); + + component.saveModal(); + + expect(saveSpy).toHaveBeenCalledWith('meeting-notes.md'); + expect(closeSpy).toHaveBeenCalledWith(false); + }); + + it('should fallback to default name when file name is empty', () => { + const saveSpy = jest.fn(); + + component.saveFileName.subscribe(saveSpy); + component.fileName.set(' '); + + component.saveModal(); + + expect(saveSpy).toHaveBeenCalledWith('document.md'); + }); +}); diff --git a/tests/app/services/shortcuts.service.spec.ts b/tests/app/services/shortcuts.service.spec.ts index a6e81d5..7cd6721 100644 --- a/tests/app/services/shortcuts.service.spec.ts +++ b/tests/app/services/shortcuts.service.spec.ts @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; -import { ShortcutsService } from '../../../src/app/utils/services/shortcuts.service'; + +import { ShortcutsService } from '../../../src/app/services/shortcuts.service'; import type { Shortcut } from '../../../src/app/utils/types/shortcut'; function createKeyboardEvent(key: string, options: Partial = {}): KeyboardEvent {