@@ -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 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('');
+ });
+ });
+
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('');
+ });
+ });
+
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('');
+ });
+
+ 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(``);
+ 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;
+}