From 600f2b619624ebccfe8c9bf7871bf9c78603752c Mon Sep 17 00:00:00 2001 From: Gato Date: Tue, 26 May 2026 18:48:25 +0200 Subject: [PATCH] ajout d'image --- .../issues/issue-comments/issue-comments.html | 4 +- .../issue-comments/issue-comments.spec.ts | 52 +++++++++++ .../issues/issue-comments/issue-comments.ts | 14 +++ src/app/issues/issue-detail/issue-detail.html | 1 + .../issues/issue-detail/issue-detail.spec.ts | 31 +++++++ src/app/issues/issue-detail/issue-detail.ts | 10 +++ src/app/issues/paste-image.util.spec.ts | 88 +++++++++++++++++++ src/app/issues/paste-image.util.ts | 27 ++++++ 8 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 src/app/issues/paste-image.util.spec.ts create mode 100644 src/app/issues/paste-image.util.ts diff --git a/src/app/issues/issue-comments/issue-comments.html b/src/app/issues/issue-comments/issue-comments.html index 0b56166..70e8705 100644 --- a/src/app/issues/issue-comments/issue-comments.html +++ b/src/app/issues/issue-comments/issue-comments.html @@ -23,6 +23,7 @@ rows="3" [(ngModel)]="editingCommentText" (keydown.escape)="cancelEditComment()" + (paste)="onPaste($event, 'edit')" >
@@ -39,8 +40,9 @@ aria-label="Nouveau commentaire" class="form-control form-control-sm" rows="3" - placeholder="Ajouter un commentaire... (Markdown supporté)" + placeholder="Ajouter un commentaire... (Markdown supporté, coller une image avec Ctrl+V)" [(ngModel)]="newCommentText" + (paste)="onPaste($event, 'new')" >
diff --git a/src/app/issues/issue-comments/issue-comments.spec.ts b/src/app/issues/issue-comments/issue-comments.spec.ts index 1a1fbb9..3b7d8f3 100644 --- a/src/app/issues/issue-comments/issue-comments.spec.ts +++ b/src/app/issues/issue-comments/issue-comments.spec.ts @@ -1,5 +1,6 @@ import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { afterEach, vi } from 'vitest'; import { IssueComments } from './issue-comments'; import { IssueEntity, IssuesStore } from '../issues.store'; @@ -231,6 +232,57 @@ describe('IssueComments', () => { }); }); + describe('onPaste', () => { + afterEach(() => vi.unstubAllGlobals()); + + function mockFileReader(result: string) { + vi.stubGlobal('FileReader', class { + readonly result = result; + onload: ((e: any) => void) | null = null; + readAsDataURL(_file: File) { + Promise.resolve().then(() => this.onload?.({ target: { result: this.result } })); + } + }); + } + + function makePasteEvent(ta: HTMLTextAreaElement): ClipboardEvent { + const file = new File([''], 'img.png', { type: 'image/png' }); + return { + clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] }, + preventDefault: vi.fn(), + target: ta, + } as unknown as ClipboardEvent; + } + + it('inserts image markdown into newCommentText at cursor', async () => { + mockFileReader('data:image/png;base64,test'); + (component as any).newCommentText = 'hello world'; + const ta = document.createElement('textarea'); + ta.value = 'hello world'; + ta.selectionStart = 5; + ta.selectionEnd = 5; + + (component as any).onPaste(makePasteEvent(ta), 'new'); + await Promise.resolve(); + + expect((component as any).newCommentText).toBe('hello![image](data:image/png;base64,test) world'); + }); + + it('inserts image markdown into editingCommentText at cursor', async () => { + mockFileReader('data:image/png;base64,test'); + (component as any).editingCommentText = 'some text'; + const ta = document.createElement('textarea'); + ta.value = 'some text'; + ta.selectionStart = 0; + ta.selectionEnd = 0; + + (component as any).onPaste(makePasteEvent(ta), 'edit'); + await Promise.resolve(); + + expect((component as any).editingCommentText).toContain('![image](data:image/png;base64,test)'); + }); + }); + describe('deleteComment', () => { it('removes the comment from the issue', () => { (component as any).newCommentText = 'To delete'; diff --git a/src/app/issues/issue-comments/issue-comments.ts b/src/app/issues/issue-comments/issue-comments.ts index a092319..1408481 100644 --- a/src/app/issues/issue-comments/issue-comments.ts +++ b/src/app/issues/issue-comments/issue-comments.ts @@ -2,6 +2,7 @@ import { Component, computed, inject, input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { marked } from 'marked'; +import { handleImagePaste, insertAtSelection } from '../paste-image.util'; import { IssueComment, IssuesStore } from '../issues.store'; @Component({ @@ -68,6 +69,19 @@ export class IssueComments { this.editingCommentText = ''; } + protected onPaste(event: ClipboardEvent, field: 'new' | 'edit'): void { + const ta = event.target as HTMLTextAreaElement; + const start = ta.selectionStart; + const end = ta.selectionEnd; + handleImagePaste(event, (md) => { + if (field === 'new') { + this.newCommentText = insertAtSelection(ta, this.newCommentText, start, end, md); + } else { + this.editingCommentText = insertAtSelection(ta, this.editingCommentText, start, end, md); + } + }); + } + protected cancelEditComment(): void { this.editingCommentId = null; this.editingCommentText = ''; diff --git a/src/app/issues/issue-detail/issue-detail.html b/src/app/issues/issue-detail/issue-detail.html index cfe8de5..c53941a 100644 --- a/src/app/issues/issue-detail/issue-detail.html +++ b/src/app/issues/issue-detail/issue-detail.html @@ -165,6 +165,7 @@ placeholder="Ajouter une description..." [(ngModel)]="issue.description" (blur)="editingDescription = false; saveIssue()" + (paste)="onDescriptionPaste($event)" > } @else {
{ }); }); + describe('onDescriptionPaste', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('inserts image markdown into issue.description', async () => { + vi.stubGlobal('FileReader', class { + readonly result = 'data:image/png;base64,xyz'; + onload: ((e: any) => void) | null = null; + readAsDataURL(_file: File) { + Promise.resolve().then(() => this.onload?.({ target: { result: this.result } })); + } + }); + + (component as any).issue.description = 'existing'; + const ta = document.createElement('textarea'); + ta.value = 'existing'; + ta.selectionStart = 0; + ta.selectionEnd = 0; + const file = new File([''], 'img.png', { type: 'image/png' }); + const event = { + clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] }, + preventDefault: vi.fn(), + target: ta, + } as unknown as ClipboardEvent; + + (component as any).onDescriptionPaste(event); + await Promise.resolve(); + + expect((component as any).issue.description).toContain('![image](data:image/png;base64,xyz)'); + }); + }); + describe('descriptionHtml', () => { it('returns a truthy SafeHtml for markdown input', () => { (component as any).issue.description = '# Title\n**bold**'; diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index df1537c..a947f87 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -6,6 +6,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { marked } from 'marked'; import { IssueEntity, IssuesStore } from '../issues.store'; import { IssueComments } from '../issue-comments/issue-comments'; +import { handleImagePaste, insertAtSelection } from '../paste-image.util'; @Component({ selector: 'app-issue-detail', @@ -205,6 +206,15 @@ export class IssueDetail { return this.issueTypeValue === 'Epic'; } + protected onDescriptionPaste(event: ClipboardEvent): void { + const ta = event.target as HTMLTextAreaElement; + const start = ta.selectionStart; + const end = ta.selectionEnd; + handleImagePaste(event, (md) => { + this.issue.description = insertAtSelection(ta, this.issue.description, start, end, md); + }); + } + protected get descriptionHtml(): SafeHtml { const html = marked.parse(this.issue.description || '') as string; return this.sanitizer.bypassSecurityTrustHtml(html); diff --git a/src/app/issues/paste-image.util.spec.ts b/src/app/issues/paste-image.util.spec.ts new file mode 100644 index 0000000..0910583 --- /dev/null +++ b/src/app/issues/paste-image.util.spec.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { handleImagePaste, insertAtSelection } from './paste-image.util'; + +function makePasteEvent(items: Partial[]): ClipboardEvent { + return { + clipboardData: { items }, + preventDefault: vi.fn(), + target: document.createElement('textarea'), + } as unknown as ClipboardEvent; +} + +function mockFileReader(result: string) { + vi.stubGlobal('FileReader', class { + readonly result = result; + onload: ((e: any) => void) | null = null; + readAsDataURL(_file: File) { + Promise.resolve().then(() => this.onload?.({ target: { result: this.result } })); + } + }); +} + +describe('insertAtSelection', () => { + it('inserts at cursor position', () => { + const ta = document.createElement('textarea'); + expect(insertAtSelection(ta, 'hello world', 5, 5, '!')).toBe('hello! world'); + }); + + it('replaces selected text', () => { + const ta = document.createElement('textarea'); + expect(insertAtSelection(ta, 'hello world', 6, 11, 'Angular')).toBe('hello Angular'); + }); + + it('inserts at the start', () => { + const ta = document.createElement('textarea'); + expect(insertAtSelection(ta, 'world', 0, 0, 'hello ')).toBe('hello world'); + }); + + it('appends at the end', () => { + const ta = document.createElement('textarea'); + expect(insertAtSelection(ta, 'hello', 5, 5, ' world')).toBe('hello world'); + }); +}); + +describe('handleImagePaste', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('calls onImage with markdown and calls preventDefault', async () => { + mockFileReader('data:image/png;base64,abc123'); + const file = new File([''], 'img.png', { type: 'image/png' }); + const event = makePasteEvent([{ type: 'image/png', getAsFile: () => file }]); + + const onImage = vi.fn(); + handleImagePaste(event, onImage); + + expect((event as any).preventDefault).toHaveBeenCalled(); + await Promise.resolve(); + expect(onImage).toHaveBeenCalledWith('![image](data:image/png;base64,abc123)'); + }); + + it('does nothing when clipboard has no image', () => { + const event = makePasteEvent([{ type: 'text/plain', getAsFile: () => null }]); + const onImage = vi.fn(); + handleImagePaste(event, onImage); + expect((event as any).preventDefault).not.toHaveBeenCalled(); + expect(onImage).not.toHaveBeenCalled(); + }); + + it('does nothing when clipboardData is null', () => { + const event = { clipboardData: null, preventDefault: vi.fn() } as unknown as ClipboardEvent; + handleImagePaste(event, vi.fn()); + expect((event as any).preventDefault).not.toHaveBeenCalled(); + }); + + it('does nothing when items is empty', () => { + const event = makePasteEvent([]); + const onImage = vi.fn(); + handleImagePaste(event, onImage); + expect(onImage).not.toHaveBeenCalled(); + }); + + it('skips items where getAsFile returns null', async () => { + const event = makePasteEvent([{ type: 'image/png', getAsFile: () => null }]); + const onImage = vi.fn(); + handleImagePaste(event, onImage); + await Promise.resolve(); + expect(onImage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/issues/paste-image.util.ts b/src/app/issues/paste-image.util.ts new file mode 100644 index 0000000..6e8adc7 --- /dev/null +++ b/src/app/issues/paste-image.util.ts @@ -0,0 +1,27 @@ +export function handleImagePaste(event: ClipboardEvent, onImage: (markdown: string) => void): void { + const items = event.clipboardData?.items; + if (!items) return; + for (const item of Array.from(items)) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (!file) continue; + event.preventDefault(); + const reader = new FileReader(); + reader.onload = (e) => onImage(`![image](${e.target!.result as string})`); + reader.readAsDataURL(file); + return; + } + } +} + +export function insertAtSelection( + textarea: HTMLTextAreaElement, + currentValue: string, + start: number, + end: number, + insertion: string, +): string { + const next = currentValue.slice(0, start) + insertion + currentValue.slice(end); + setTimeout(() => { textarea.selectionStart = textarea.selectionEnd = start + insertion.length; }); + return next; +}