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:
@@ -50,6 +50,7 @@ import {
|
||||
Workflow,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { IssueCardComponent } from '../ui/issue-card';
|
||||
import { RichEditorComponent } from '../ui/rich-editor';
|
||||
import {
|
||||
options,
|
||||
pointOptions,
|
||||
@@ -97,6 +98,7 @@ const NOBODY = '__nobody__';
|
||||
PiSlideToggleComponent,
|
||||
PiTextFieldComponent,
|
||||
PiTextareaComponent,
|
||||
RichEditorComponent,
|
||||
PiLabelDirective,
|
||||
],
|
||||
providers: [provideBoardFacade()],
|
||||
@@ -337,15 +339,16 @@ const NOBODY = '__nobody__';
|
||||
<pi-text-field [formField]="f.title" [placeholder]="titlePlaceholder()">
|
||||
<pi-label [isRequired]="true">Título</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-textarea
|
||||
<ts-rich-editor
|
||||
label="Descripción"
|
||||
[formField]="f.description"
|
||||
[placeholder]="
|
||||
template()?.description ??
|
||||
'Pasos para reproducir, criterios de aceptación…'
|
||||
"
|
||||
>
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
[members]="mentionable()"
|
||||
[me]="me()"
|
||||
/>
|
||||
@if (template(); as t) {
|
||||
<p class="ts-muted ts-template-hint">
|
||||
Plantilla de {{ typeLabel() }}:
|
||||
@@ -473,10 +476,14 @@ export class BoardPage {
|
||||
|
||||
protected readonly board = inject(BoardFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
/** Who `@` offers in the new ticket's description. */
|
||||
protected readonly mentionable = computed(() =>
|
||||
this.project.members().map((m) => m.user),
|
||||
);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly auth = inject(AuthFacade);
|
||||
private readonly me = computed(() => this.auth.user()?.id ?? null);
|
||||
protected readonly me = computed(() => this.auth.user()?.id ?? null);
|
||||
private readonly createForm =
|
||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||
private readonly reasonForm =
|
||||
|
||||
@@ -445,30 +445,6 @@ a.ts-key {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.ts-mentions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.ts-mentions__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.625rem 0.25rem 0.25rem;
|
||||
border: 1px solid rgba(127, 127, 127, 0.3);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ts-mentions__option:hover,
|
||||
.ts-mentions__option:focus-visible {
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
// Code that mentions the ticket (from the Git integration).
|
||||
.ts-dev {
|
||||
display: grid;
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
PiActionButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
} from '@pi/ui';
|
||||
import {
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
ProposeFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import { ProposalResult } from '@task-stream/web/tracker/domain';
|
||||
import { MarkdownHintComponent } from '../ui/markdown';
|
||||
import { RichEditorComponent } from '../ui/rich-editor';
|
||||
|
||||
/**
|
||||
* /p/:key/proponer — anyone, without an account, reports a bug or proposes
|
||||
@@ -30,11 +29,10 @@ import { MarkdownHintComponent } from '../ui/markdown';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
MarkdownHintComponent,
|
||||
RichEditorComponent,
|
||||
PiActionButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
],
|
||||
template: `
|
||||
@@ -114,17 +112,16 @@ import { MarkdownHintComponent } from '../ui/markdown';
|
||||
>
|
||||
<pi-label [isRequired]="true">Título</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-textarea
|
||||
<ts-rich-editor
|
||||
label="Detalles"
|
||||
[placeholder]="
|
||||
kind() === 'BUG'
|
||||
? 'Qué hiciste, qué esperabas y qué pasó. El navegador o el móvil ayudan.'
|
||||
: 'Qué te gustaría y para qué te serviría.'
|
||||
"
|
||||
[(value)]="description"
|
||||
>
|
||||
<pi-label>Detalles</pi-label>
|
||||
</pi-textarea>
|
||||
<ts-markdown-hint [text]="description()" />
|
||||
(submitted)="submit(p.key)"
|
||||
/>
|
||||
<pi-text-field placeholder="Opcional" [(value)]="author">
|
||||
<pi-label>Tu nombre o alias</pi-label>
|
||||
</pi-text-field>
|
||||
|
||||
Reference in New Issue
Block a user