ajout d'image

This commit is contained in:
2026-05-26 18:48:25 +02:00
parent e8db166ee5
commit 663234d5c8
8 changed files with 226 additions and 1 deletions
@@ -23,6 +23,7 @@
rows="3" rows="3"
[(ngModel)]="editingCommentText" [(ngModel)]="editingCommentText"
(keydown.escape)="cancelEditComment()" (keydown.escape)="cancelEditComment()"
(paste)="onPaste($event, 'edit')"
></textarea> ></textarea>
<div class="d-flex gap-2 mt-2"> <div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-sm btn-primary" (click)="saveEditComment()" [disabled]="!editingCommentText.trim()">Enregistrer</button> <button type="button" class="btn btn-sm btn-primary" (click)="saveEditComment()" [disabled]="!editingCommentText.trim()">Enregistrer</button>
@@ -39,8 +40,9 @@
aria-label="Nouveau commentaire" aria-label="Nouveau commentaire"
class="form-control form-control-sm" class="form-control form-control-sm"
rows="3" rows="3"
placeholder="Ajouter un commentaire... (Markdown supporté)" placeholder="Ajouter un commentaire... (Markdown supporté, coller une image avec Ctrl+V)"
[(ngModel)]="newCommentText" [(ngModel)]="newCommentText"
(paste)="onPaste($event, 'new')"
></textarea> ></textarea>
<div class="d-flex justify-content-end mt-2"> <div class="d-flex justify-content-end mt-2">
<button type="button" class="btn btn-sm btn-primary" (click)="addComment()" [disabled]="!newCommentText.trim()">Ajouter</button> <button type="button" class="btn btn-sm btn-primary" (click)="addComment()" [disabled]="!newCommentText.trim()">Ajouter</button>
@@ -1,5 +1,6 @@
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { afterEach, vi } from 'vitest';
import { IssueComments } from './issue-comments'; import { IssueComments } from './issue-comments';
import { IssueEntity, IssuesStore } from '../issues.store'; 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', () => { describe('deleteComment', () => {
it('removes the comment from the issue', () => { it('removes the comment from the issue', () => {
(component as any).newCommentText = 'To delete'; (component as any).newCommentText = 'To delete';
@@ -2,6 +2,7 @@ import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked'; import { marked } from 'marked';
import { handleImagePaste, insertAtSelection } from '../paste-image.util';
import { IssueComment, IssuesStore } from '../issues.store'; import { IssueComment, IssuesStore } from '../issues.store';
@Component({ @Component({
@@ -68,6 +69,19 @@ export class IssueComments {
this.editingCommentText = ''; 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 { protected cancelEditComment(): void {
this.editingCommentId = null; this.editingCommentId = null;
this.editingCommentText = ''; this.editingCommentText = '';
@@ -165,6 +165,7 @@
placeholder="Ajouter une description..." placeholder="Ajouter une description..."
[(ngModel)]="issue.description" [(ngModel)]="issue.description"
(blur)="editingDescription = false; saveIssue()" (blur)="editingDescription = false; saveIssue()"
(paste)="onDescriptionPaste($event)"
></textarea> ></textarea>
} @else { } @else {
<div <div
@@ -470,6 +470,37 @@ describe('IssueDetail — existing issue', () => {
}); });
}); });
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', () => { describe('descriptionHtml', () => {
it('returns a truthy SafeHtml for markdown input', () => { it('returns a truthy SafeHtml for markdown input', () => {
(component as any).issue.description = '# Title\n**bold**'; (component as any).issue.description = '# Title\n**bold**';
@@ -6,6 +6,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { marked } from 'marked'; import { marked } from 'marked';
import { IssueEntity, IssuesStore } from '../issues.store'; import { IssueEntity, IssuesStore } from '../issues.store';
import { IssueComments } from '../issue-comments/issue-comments'; import { IssueComments } from '../issue-comments/issue-comments';
import { handleImagePaste, insertAtSelection } from '../paste-image.util';
@Component({ @Component({
selector: 'app-issue-detail', selector: 'app-issue-detail',
@@ -205,6 +206,15 @@ export class IssueDetail {
return this.issueTypeValue === 'Epic'; 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 { protected get descriptionHtml(): SafeHtml {
const html = marked.parse(this.issue.description || '') as string; const html = marked.parse(this.issue.description || '') as string;
return this.sanitizer.bypassSecurityTrustHtml(html); return this.sanitizer.bypassSecurityTrustHtml(html);
+88
View File
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { handleImagePaste, insertAtSelection } from './paste-image.util';
function makePasteEvent(items: Partial<DataTransferItem>[]): 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();
});
});
+27
View File
@@ -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;
}