feat(web): write descriptions and comments with a visual editor
ts-rich-editor (TipTap) shows the formatting while writing: bold, italic, strike, code, headings, lists, task lists, quotes, code blocks and links, with @ suggesting the project's members and pasted screenshots uploaded as attachments. It reads and writes the same Markdown the API keeps (mentions as @[Name](id), images as attachment:<id>, tables too), so nothing already written changes. Used for comments, replies, editing a comment or a description, a new ticket from the board and the public proposal form; the Markdown hint and the paste directive are gone. List markers sat on a line of their own, and task lists showed a stray bullet, in the editor and when read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
88
libs/web/tracker/presenters/src/lib/ui/rich-text.spec.ts
Normal file
88
libs/web/tracker/presenters/src/lib/ui/rich-text.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { markdownOf, richTextExtensions } from './rich-text';
|
||||
|
||||
/** Markdown in, the editor's Markdown out: what an edit that changes nothing saves. */
|
||||
function roundTrip(markdown: string): string {
|
||||
const editor = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: richTextExtensions(),
|
||||
content: markdown,
|
||||
contentType: 'markdown',
|
||||
});
|
||||
const out = markdownOf(editor);
|
||||
editor.destroy();
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('rich text', () => {
|
||||
it('keeps mentions as the API reads them', () => {
|
||||
expect(roundTrip('Hola @[Ana López](101), ¿lo miras?')).toBe(
|
||||
'Hola @[Ana López](101), ¿lo miras?',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps pasted images pointing at their attachment', () => {
|
||||
expect(roundTrip('Mira:\n\n')).toContain(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps formatting, lists, task lists, quotes, code and links', () => {
|
||||
const text = [
|
||||
'## Pasos',
|
||||
'',
|
||||
'Texto con **negrita**, *cursiva*, ~~tachado~~ y `código`.',
|
||||
'',
|
||||
'- uno',
|
||||
'- dos',
|
||||
'',
|
||||
'1. primero',
|
||||
'2. segundo',
|
||||
'',
|
||||
'- [ ] pendiente',
|
||||
'- [x] hecho',
|
||||
'',
|
||||
'> una cita',
|
||||
'',
|
||||
'```',
|
||||
'const a = 1;',
|
||||
'```',
|
||||
'',
|
||||
'[enlace](https://example.com)',
|
||||
].join('\n');
|
||||
const out = roundTrip(text);
|
||||
for (const piece of [
|
||||
'## Pasos',
|
||||
'**negrita**',
|
||||
'*cursiva*',
|
||||
'~~tachado~~',
|
||||
'`código`',
|
||||
'- uno',
|
||||
'1. primero',
|
||||
'- [ ] pendiente',
|
||||
'- [x] hecho',
|
||||
'> una cita',
|
||||
'const a = 1;',
|
||||
'[enlace](https://example.com)',
|
||||
])
|
||||
expect(out).toContain(piece);
|
||||
});
|
||||
|
||||
it('keeps tables', () => {
|
||||
const out = roundTrip('| a | b |\n| --- | --- |\n| 1 | 2 |');
|
||||
expect(out).toMatch(/\|\s*a\s*\|\s*b\s*\|/);
|
||||
expect(out).toMatch(/\|\s*1\s*\|\s*2\s*\|/);
|
||||
});
|
||||
|
||||
it('answers an empty string for an empty editor', () => {
|
||||
expect(roundTrip('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rich text, line breaks', () => {
|
||||
it('keeps a single line break as a line break', () => {
|
||||
const out = roundTrip('primera línea\nsegunda línea');
|
||||
expect(out).not.toBe('primera línea segunda línea');
|
||||
expect(out).toMatch(/primera línea(\s{2}|\\)?\nsegunda línea/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user