Merge pull request 'ajout d'image' (#21) from feat/4-ajout-image-description into develop
Reviewed-on: Bonsai/Bonsai-webapp#21
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
rows="3"
|
||||
[(ngModel)]="editingCommentText"
|
||||
(keydown.escape)="cancelEditComment()"
|
||||
(paste)="onPaste($event, 'edit')"
|
||||
></textarea>
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="button" class="btn btn-sm btn-primary" (click)="saveEditComment()" [disabled]="!editingCommentText.trim()">Enregistrer</button>
|
||||
@@ -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')"
|
||||
></textarea>
|
||||
<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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
placeholder="Ajouter une description..."
|
||||
[(ngModel)]="issue.description"
|
||||
(blur)="editingDescription = false; saveIssue()"
|
||||
(paste)="onDescriptionPaste($event)"
|
||||
></textarea>
|
||||
} @else {
|
||||
<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('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('descriptionHtml', () => {
|
||||
it('returns a truthy SafeHtml for markdown input', () => {
|
||||
(component as any).issue.description = '# Title\n**bold**';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user