ajout d'image
This commit is contained in:
@@ -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 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', () => {
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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