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:
2026-10-13 23:00:41 +02:00
parent 9892443eda
commit 836c6f63bc
16 changed files with 1714 additions and 318 deletions

View File

@@ -40,13 +40,10 @@ import {
Attachment,
Comment,
DevLink,
insertMention,
IssueLink,
IssueLinkType,
IssuePriority,
isTyping,
matches,
mentionQuery,
POINT_SCALE,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
@@ -68,9 +65,8 @@ import {
AttachmentLoader,
ImageUploader,
MarkdownComponent,
MarkdownHintComponent,
PasteImagesDirective,
} from '../ui/markdown';
import { RichEditorComponent } from '../ui/rich-editor';
const NONE = '';
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
@@ -83,8 +79,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
BlockedComponent,
ReactionsComponent,
MarkdownComponent,
MarkdownHintComponent,
PasteImagesDirective,
RichEditorComponent,
NgTemplateOutlet,
RouterLink,
FormField,
@@ -669,46 +664,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(submit)="$event.preventDefault(); reply(thread.comment)"
novalidate
>
<pi-textarea
<ts-rich-editor
[placeholder]="
'Responde a ' +
thread.comment.author.name +
'… Escribe @ para mencionar a alguien.'
'Responde a ' + thread.comment.author.name + '…'
"
[(value)]="replyBody"
[tsPasteImages]="pasteImage"
/>
<ts-markdown-hint
[text]="replyBody()"
[members]="mentionable()"
[me]="facade.me()"
[upload]="pasteImage"
[loader]="loadImage"
[images]="true"
(submitted)="reply(thread.comment)"
/>
@if (replyMentionOptions().length) {
<div
class="ts-mentions"
role="group"
aria-label="Mencionar a"
>
@for (
member of replyMentionOptions();
track member.id
) {
<button
type="button"
class="ts-mentions__option"
(click)="mentionInReply(member)"
>
<span
class="ts-comment__avatar"
aria-hidden="true"
>{{ initialsOf(member.name) }}</span
>
{{ member.name }}
</button>
}
</div>
}
<div class="ts-reply__actions">
<pi-action-button
color="primary"
@@ -748,37 +714,15 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(submit)="$event.preventDefault(); addComment()"
novalidate
>
<pi-textarea
placeholder="Deja una decisión, una explicación, un paso para reproducir… Escribe @ para mencionar a alguien."
<ts-rich-editor
placeholder="Deja una decisión, una explicación, un paso para reproducir…"
[(value)]="commentBody"
[tsPasteImages]="pasteImage"
/>
<ts-markdown-hint
[text]="commentBody()"
[members]="mentionable()"
[me]="facade.me()"
[upload]="pasteImage"
[loader]="loadImage"
[images]="true"
(submitted)="addComment()"
/>
@if (mentionOptions().length) {
<div
class="ts-mentions"
role="group"
aria-label="Mencionar a"
>
@for (member of mentionOptions(); track member.id) {
<button
type="button"
class="ts-mentions__option"
(click)="mention(member)"
>
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(member.name)
}}</span>
{{ member.name }}
</button>
}
</div>
}
<pi-file-upload
label="Adjuntar al comentario"
[multiple]="true"
@@ -964,17 +908,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-text-field [formField]="edit.title">
<pi-label [isRequired]="true">Título</pi-label>
</pi-text-field>
<pi-textarea
<ts-rich-editor
label="Descripción"
[formField]="edit.description"
[tsPasteImages]="pasteImage"
>
<pi-label>Descripción</pi-label>
</pi-textarea>
<ts-markdown-hint
[text]="editModel().description"
[members]="mentionable()"
[me]="facade.me()"
[upload]="pasteImage"
[loader]="loadImage"
[images]="true"
(submitted)="saveEdit()"
/>
<pi-text-field [formField]="edit.labels" placeholder="safari, checkout">
<pi-label>Etiquetas (separadas por comas)</pi-label>
@@ -1001,15 +942,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(submit)="$event.preventDefault(); saveComment()"
novalidate
>
<pi-textarea
<ts-rich-editor
[formField]="commentEdit.body"
[tsPasteImages]="pasteImage"
/>
<ts-markdown-hint
[text]="commentEditModel().body"
[members]="mentionable()"
[me]="facade.me()"
[upload]="pasteImage"
[loader]="loadImage"
[images]="true"
(submitted)="saveComment()"
/>
<pi-action-button
size="w100"
@@ -1167,13 +1106,9 @@ export class IssuePage {
this.facade.fileById(id);
protected readonly pasteImage: ImageUploader = (file) =>
this.facade.uploadImage(file);
/** Members matching what is typed after an `@` at the end of the comment. */
protected readonly mentionOptions = computed(() =>
this.membersMatching(this.commentBody()),
);
/** The same, for the answer being written. */
protected readonly replyMentionOptions = computed(() =>
this.membersMatching(this.replyBody()),
/** Who `@` offers in what is written here. */
protected readonly mentionable = computed(() =>
this.project.members().map((m) => m.user),
);
/** Pull requests first, then the newest. */
protected readonly devLinks = computed(() =>
@@ -1382,27 +1317,6 @@ export class IssuePage {
this.replyingTo.set(null);
}
/** Members whose name fits the `@…` being typed at the end of `text`. */
private membersMatching(text: string) {
const query = mentionQuery(text);
if (query === null) return [];
return this.project
.members()
.map((m) => m.user)
.filter(
(user) => user.id !== this.facade.me() && matches(user.name, query),
)
.slice(0, 6);
}
protected mentionInReply(user: { id: string; name: string }): void {
this.replyBody.update((text) => insertMention(text, user));
}
protected mention(user: { id: string; name: string }): void {
this.commentBody.update((text) => insertMention(text, user));
}
protected attach(): void {
this.facade.attach(this.pendingFiles());
this.pendingFiles.set([]);