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

@@ -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 =

View File

@@ -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;

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([]);

View File

@@ -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>

View File

@@ -3,13 +3,10 @@ import {
Component,
computed,
DestroyRef,
Directive,
effect,
ElementRef,
HostListener,
inject,
input,
signal,
untracked,
ViewEncapsulation,
} from '@angular/core';
@@ -17,7 +14,7 @@ import { DomSanitizer } from '@angular/platform-browser';
import { mentionSegments } from '@task-stream/web/tracker/domain';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { Observable } from 'rxjs';
import type { AttachmentLoader } from './rich-text';
/** Images pasted into a text are stored as attachments: `![name](attachment:<id>)`. */
const ATTACHMENT = /^attachment:([\w-]{1,64})$/;
@@ -129,20 +126,10 @@ export function renderMarkdown(text: string, me: string | null = null): string {
});
}
/** Fetches an attachment's bytes (with the session), by id. */
export type AttachmentLoader = (attachmentId: string) => Observable<Blob>;
export type { AttachmentLoader, ImageUploader } from './rich-text';
/**
* Formatted text: Markdown rendered safely. Pasted images (`attachment:`)
* are fetched with `loader`, since a private project's files need the
* session and an `<img>` cannot send it.
*/
@Component({
selector: 'ts-markdown',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: '<div class="ts-md" [innerHTML]="html()"></div>',
styles: `
/** How formatted text looks, read (`ts-markdown`) or being written (`ts-rich-editor`). */
export const MARKDOWN_STYLES = `
.ts-md {
display: block;
min-width: 0;
@@ -184,7 +171,16 @@ export type AttachmentLoader = (attachmentId: string) => Observable<Blob>;
ol {
padding-left: 1.375rem;
}
li:has(> input[type='checkbox']) {
/* The app's base styles put markers inside, which leaves them on a line
of their own when the item holds a paragraph (loose lists, the editor). */
li {
list-style-position: outside;
> p {
margin: 0;
}
}
li:has(> input[type='checkbox']),
li:has(> p:first-child > input[type='checkbox']) {
list-style: none;
margin-left: -1.375rem;
}
@@ -257,7 +253,19 @@ export type AttachmentLoader = (attachmentId: string) => Observable<Blob>;
background: color-mix(in oklab, var(--ts-brand) 30%, transparent);
}
}
`,
`;
/**
* Formatted text: Markdown rendered safely. Pasted images (`attachment:`)
* are fetched with `loader`, since a private project's files need the
* session and an `<img>` cannot send it.
*/
@Component({
selector: 'ts-markdown',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: '<div class="ts-md" [innerHTML]="html()"></div>',
styles: MARKDOWN_STYLES,
})
export class MarkdownComponent {
readonly text = input<string | null | undefined>('');
@@ -312,99 +320,3 @@ export class MarkdownComponent {
}
}
}
/**
* Under a text box: that it takes Markdown, and a preview of what will show.
*/
@Component({
selector: 'ts-markdown-hint',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MarkdownComponent],
template: `
<div class="ts-md-hint">
<span class="ts-muted"
>Admite Markdown: **negrita**, _cursiva_, <code>código</code>, listas y
enlaces{{ images() ? '; pega capturas con Ctrl+V' : '' }}.</span
>
<button
type="button"
class="ts-plain-link"
[disabled]="!text()?.trim()"
(click)="open.set(!open())"
>
{{ open() ? 'Ocultar vista previa' : 'Vista previa' }}
</button>
</div>
@if (open() && text()?.trim()) {
<ts-markdown
class="ts-md-preview"
[text]="text()"
[me]="me()"
[loader]="loader()"
/>
}
`,
styles: `
:host {
display: grid;
gap: 0.5rem;
}
.ts-md-hint {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
align-items: baseline;
font-size: 0.75rem;
}
.ts-md-preview {
padding: 0.75rem 0.875rem;
border: 1px dashed var(--ts-line-strong, var(--ts-line));
border-radius: 0.625rem;
}
`,
})
export class MarkdownHintComponent {
readonly text = input<string | null | undefined>('');
readonly me = input<string | null>(null);
readonly loader = input<AttachmentLoader | null>(null);
/** Whether pasting images is on for the box above. */
readonly images = input(false);
protected readonly open = signal(false);
}
/** Uploads a pasted image and answers its attachment id. */
export type ImageUploader = (file: File) => Observable<string>;
/**
* On a textarea (or a component wrapping one): pasting an image uploads it
* and writes `![name](attachment:<id>)` where the caret was. The text is
* changed through an `input` event, so whatever binds the field sees it.
*/
@Directive({ selector: '[tsPasteImages]' })
export class PasteImagesDirective {
readonly tsPasteImages = input<ImageUploader | null>(null);
private readonly destroyRef = inject(DestroyRef);
@HostListener('paste', ['$event'])
onPaste(event: ClipboardEvent): void {
const upload = this.tsPasteImages();
const files = Array.from(event.clipboardData?.files ?? []).filter((f) =>
f.type.startsWith('image/'),
);
const area = event.target as HTMLTextAreaElement | null;
if (!upload || !files.length || area?.tagName !== 'TEXTAREA') return;
event.preventDefault();
let caret = area.selectionStart ?? area.value.length;
for (const file of files) {
const name = (file.name || 'captura').replace(/[[\]()\n]/g, ' ');
const sub = upload(file).subscribe((id) => {
const snippet = `\n![${name}](attachment:${id})\n`;
area.value =
area.value.slice(0, caret) + snippet + area.value.slice(caret);
caret += snippet.length;
area.dispatchEvent(new Event('input', { bubbles: true }));
});
this.destroyRef.onDestroy(() => sub.unsubscribe());
}
}
}

View File

@@ -0,0 +1,653 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
ElementRef,
inject,
input,
model,
output,
signal,
untracked,
viewChild,
ViewEncapsulation,
} from '@angular/core';
import type { FormValueControl } from '@angular/forms/signals';
import { matches } from '@task-stream/web/tracker/domain';
import { Editor } from '@tiptap/core';
import { SuggestionProps } from '@tiptap/suggestion';
import { MARKDOWN_STYLES } from './markdown';
import {
AttachmentLoader,
ImageUploader,
markdownOf,
Mentionable,
richTextExtensions,
} from './rich-text';
/** A button of the toolbar: what it shows, does and when it is on. */
interface Tool {
label: string;
title: string;
run: (editor: Editor) => void;
active?: (editor: Editor) => boolean;
/** For the ones shown as text: bold, italic… */
style?: string;
}
const TOOLS: (Tool | 'gap')[] = [
{
label: 'B',
title: 'Negrita (Ctrl+B)',
style: 'font-weight: 700',
run: (e) => e.chain().focus().toggleBold().run(),
active: (e) => e.isActive('bold'),
},
{
label: 'I',
title: 'Cursiva (Ctrl+I)',
style: 'font-style: italic',
run: (e) => e.chain().focus().toggleItalic().run(),
active: (e) => e.isActive('italic'),
},
{
label: 'S',
title: 'Tachado',
style: 'text-decoration: line-through',
run: (e) => e.chain().focus().toggleStrike().run(),
active: (e) => e.isActive('strike'),
},
{
label: '</>',
title: 'Código',
style:
'font-family: var(--ts-mono, ui-monospace, monospace); font-size: 0.75rem',
run: (e) => e.chain().focus().toggleCode().run(),
active: (e) => e.isActive('code'),
},
'gap',
{
label: 'H',
title: 'Título',
style: 'font-weight: 700',
run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
active: (e) => e.isActive('heading'),
},
{
label: '•',
title: 'Lista',
style: 'font-size: 1.125rem',
run: (e) => e.chain().focus().toggleBulletList().run(),
active: (e) => e.isActive('bulletList'),
},
{
label: '1.',
title: 'Lista numerada',
run: (e) => e.chain().focus().toggleOrderedList().run(),
active: (e) => e.isActive('orderedList'),
},
{
label: '☑',
title: 'Lista de tareas',
run: (e) => e.chain().focus().toggleTaskList().run(),
active: (e) => e.isActive('taskList'),
},
{
label: '❝',
title: 'Cita',
style: 'font-size: 1rem',
run: (e) => e.chain().focus().toggleBlockquote().run(),
active: (e) => e.isActive('blockquote'),
},
{
label: '{ }',
title: 'Bloque de código',
style:
'font-family: var(--ts-mono, ui-monospace, monospace); font-size: 0.75rem',
run: (e) => e.chain().focus().toggleCodeBlock().run(),
active: (e) => e.isActive('codeBlock'),
},
];
/** The `@` being typed: who matches, where the caret is, which one is picked. */
interface Suggesting {
items: Mentionable[];
pick: (item: Mentionable) => void;
left: number;
top: number;
index: number;
}
/**
* A description or a comment, written as it will look: bold, lists, task
* lists, quotes, code, links, tables, `@` to mention someone and pasted
* screenshots. It reads and writes Markdown (`value`), which is what the
* API keeps, so everything already written opens here as it was.
*
* Works with `[(value)]` and with signal forms' `[formField]`.
*/
@Component({
selector: 'ts-rich-editor',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
@if (label()) {
<span class="ts-rich__label">{{ label() }}</span>
}
<div
class="ts-rich"
[class.is-focused]="focused()"
[class.is-disabled]="disabled()"
>
@if (!disabled()) {
<div class="ts-rich__bar" role="toolbar" [attr.aria-label]="'Formato'">
@for (tool of tools; track $index) {
@if (tool === 'gap') {
<span class="ts-rich__gap" aria-hidden="true"></span>
} @else {
<button
type="button"
class="ts-rich__tool"
[class.is-on]="isOn(tool)"
[attr.aria-pressed]="isOn(tool)"
[attr.aria-label]="tool.title"
[title]="tool.title"
[attr.style]="tool.style ?? null"
(mousedown)="$event.preventDefault()"
(click)="use(tool)"
>
{{ tool.label }}
</button>
}
}
<span class="ts-rich__gap" aria-hidden="true"></span>
<button
type="button"
class="ts-rich__tool"
[class.is-on]="linkOn()"
[attr.aria-pressed]="linkOn()"
aria-label="Enlace"
title="Enlace"
(mousedown)="$event.preventDefault()"
(click)="toggleLink()"
>
🔗
</button>
</div>
@if (linking()) {
<form
class="ts-rich__link"
(submit)="$event.preventDefault(); applyLink()"
>
<input
#linkInput
type="url"
placeholder="https://…"
aria-label="Dirección del enlace"
[value]="linkHref()"
(input)="linkHref.set($any($event.target).value)"
(keydown.escape)="linking.set(false)"
/>
<button type="submit" class="ts-plain-link">Aplicar</button>
<button
type="button"
class="ts-plain-link"
(click)="linking.set(false)"
>
Cancelar
</button>
</form>
}
}
<div #host class="ts-rich__body"></div>
@if (suggesting(); as s) {
<div
class="ts-rich__mentions"
role="listbox"
aria-label="Mencionar a"
[style.left.px]="s.left"
[style.top.px]="s.top"
>
@for (item of s.items; track item.id; let i = $index) {
<button
type="button"
role="option"
class="ts-rich__mention"
[class.is-on]="i === s.index"
[attr.aria-selected]="i === s.index"
(mousedown)="$event.preventDefault(); s.pick(item)"
>
{{ item.name }}
</button>
}
</div>
}
</div>
@if (hint()) {
<p class="ts-rich__hint">{{ hint() }}</p>
}
`,
styles: [
MARKDOWN_STYLES,
`
ts-rich-editor {
display: block;
/* The next @pi/ui field floats its label above its own box. */
margin-bottom: 0.75rem;
}
/* Like the label of @pi/ui's fields. */
.ts-rich__label {
display: block;
margin-bottom: 0.25rem;
padding-left: 1rem;
color: var(--ts-muted);
font-size: 0.75rem;
}
.ts-rich {
position: relative;
border: 1px solid var(--ts-line-strong, var(--ts-line));
border-radius: 0.75rem;
background: var(--ts-surface, transparent);
transition: border-color 0.15s ease;
&.is-focused {
border-color: var(--ts-brand);
}
&.is-disabled {
opacity: 0.7;
}
}
.ts-rich__bar {
display: flex;
flex-wrap: wrap;
gap: 0.125rem;
align-items: center;
padding: 0.3125rem 0.375rem;
border-bottom: 1px solid var(--ts-line);
}
.ts-rich__gap {
width: 1px;
height: 1.125rem;
margin: 0 0.25rem;
background: var(--ts-line);
}
/* @pi/ui styles every <button> too specifically to override without !important. */
.ts-rich__tool {
display: inline-grid;
place-items: center;
min-width: 1.875rem !important;
height: 1.875rem !important;
padding: 0 0.375rem !important;
border: 0 !important;
border-radius: 0.4375rem !important;
background: transparent !important;
color: var(--ts-ink) !important;
font: inherit;
font-size: 0.875rem;
line-height: 1;
cursor: pointer;
&:hover {
background: color-mix(
in srgb,
var(--ts-ink) 8%,
transparent
) !important;
}
&.is-on {
background: color-mix(
in srgb,
var(--ts-brand) 16%,
transparent
) !important;
color: var(--ts-brand) !important;
}
}
.ts-rich__link {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding: 0.375rem 0.625rem;
border-bottom: 1px solid var(--ts-line);
font-size: 0.8125rem;
input {
flex: 1 1 14rem;
min-width: 0;
padding: 0.3125rem 0.5rem;
border: 1px solid var(--ts-line);
border-radius: 0.375rem;
background: transparent;
color: inherit;
font: inherit;
}
}
.ts-rich__body .ProseMirror {
min-height: var(--ts-rich-min-height, 6rem);
max-height: 32rem;
overflow-y: auto;
padding: 0.75rem 0.875rem;
outline: none;
> p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
color: var(--ts-muted);
opacity: 0.65;
pointer-events: none;
}
ul[data-type='taskList'] {
padding-left: 0;
list-style: none;
li {
display: flex;
gap: 0.5rem;
align-items: baseline;
> label {
flex: none;
user-select: none;
}
> div {
flex: 1;
min-width: 0;
> p {
margin: 0;
}
}
}
}
img.ProseMirror-selectednode {
outline: 2px solid var(--ts-brand);
}
table p {
margin: 0;
}
}
.ts-rich__mentions {
position: absolute;
z-index: 20;
display: grid;
min-width: 12rem;
max-width: 18rem;
padding: 0.25rem;
border: 1px solid var(--ts-line);
border-radius: 0.625rem;
background: var(--ts-surface, #fff);
box-shadow: 0 0.5rem 1.5rem
color-mix(in srgb, var(--ts-ink) 14%, transparent);
}
.ts-rich__mention {
display: block;
width: 100%;
min-width: 0 !important;
height: auto !important;
padding: 0.375rem 0.625rem !important;
border: 0 !important;
border-radius: 0.4375rem !important;
background: transparent !important;
color: inherit !important;
font: inherit;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
&.is-on {
background: color-mix(
in srgb,
var(--ts-brand) 14%,
transparent
) !important;
}
}
.ts-rich__hint {
margin: 0.375rem 0 0;
color: var(--ts-muted);
font-size: 0.75rem;
}
`,
],
})
export class RichEditorComponent implements FormValueControl<string> {
/** Markdown. */
readonly value = model<string>('');
readonly disabled = input(false);
readonly placeholder = input('');
/** Shown above, as the field's name. */
readonly label = input('');
/** Who `@` offers; leave it empty where nobody can be mentioned. */
readonly members = input<Mentionable[]>([]);
/** Signed-in user's id: not offered to mention oneself. */
readonly me = input<string | null>(null);
/** Turns pasting screenshots on. */
readonly upload = input<ImageUploader | null>(null);
readonly loader = input<AttachmentLoader | null>(null);
/** Ctrl/Cmd+Enter. */
readonly submitted = output<void>();
readonly touch = output<void>();
protected readonly tools = TOOLS;
protected readonly focused = signal(false);
protected readonly suggesting = signal<Suggesting | null>(null);
protected readonly linking = signal(false);
protected readonly linkHref = signal('');
/** Bumped on every change of the editor, so the toolbar reflects it. */
private readonly tick = signal(0);
protected readonly linkOn = computed(() => {
this.tick();
return !!this.editor?.isActive('link');
});
protected readonly hint = computed(() =>
[
this.members().length ? 'Escribe @ para mencionar a alguien' : '',
this.upload() ? 'pega capturas con Ctrl+V' : '',
]
.filter(Boolean)
.join(' · '),
);
private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
private readonly root = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly destroyRef = inject(DestroyRef);
private editor: Editor | null = null;
/** What the editor last wrote to `value`, to tell it from outside changes. */
private written = '';
constructor() {
afterNextRender(() => this.create());
// From outside (cleared after sending, loaded into a form): into the editor.
effect(() => {
const value = this.value() ?? '';
untracked(() => {
if (!this.editor || value === this.written) return;
this.written = value;
this.editor.commands.setContent(value, {
contentType: 'markdown',
emitUpdate: false,
});
this.tick.update((n) => n + 1);
});
});
effect(() => {
const editable = !this.disabled();
untracked(() => this.editor?.setEditable(editable));
});
this.destroyRef.onDestroy(() => this.editor?.destroy());
}
protected isOn(tool: Tool): boolean {
this.tick();
return !!this.editor && !!tool.active?.(this.editor);
}
protected use(tool: Tool): void {
if (this.editor) tool.run(this.editor);
}
protected toggleLink(): void {
const editor = this.editor;
if (!editor) return;
if (editor.isActive('link')) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
this.linkHref.set('');
this.linking.set(true);
queueMicrotask(() =>
this.root.nativeElement
.querySelector<HTMLInputElement>('.ts-rich__link input')
?.focus(),
);
}
protected applyLink(): void {
const href = this.linkHref().trim();
this.linking.set(false);
if (!this.editor || !/^(https?:\/\/|mailto:)/i.test(href)) return;
const chain = this.editor.chain().focus().extendMarkRange('link');
// With nothing selected, the address is the text.
if (this.editor.state.selection.empty)
chain
.insertContent({
type: 'text',
text: href,
marks: [{ type: 'link', attrs: { href } }],
})
.run();
// The caret after the link, so what comes next is not written over it.
else
chain
.setLink({ href })
.setTextSelection(this.editor.state.selection.to)
// …and what is typed there is not part of it.
.command(({ tr }) => {
tr.setStoredMarks([]);
return true;
})
.run();
}
private create(): void {
this.written = this.value() ?? '';
this.editor = new Editor({
element: this.host().nativeElement,
editable: !this.disabled(),
content: this.written,
contentType: 'markdown',
extensions: richTextExtensions({
placeholder: () => this.placeholder(),
loader: this.loader(),
onSubmit: () => this.submitted.emit(),
suggestion: {
items: ({ query }) =>
this.members()
.filter((m) => m.id !== this.me() && matches(m.name, query))
.slice(0, 6),
// The mention node takes `{ id, label }`.
command: ({ editor, range, props }) =>
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: 'mention',
attrs: { id: props.id, label: props.name },
},
{ type: 'text', text: ' ' },
])
.run(),
render: () => ({
onStart: (props) => this.suggest(props),
onUpdate: (props) => this.suggest(props),
onKeyDown: ({ event }) => this.suggestKey(event),
onExit: () => this.suggesting.set(null),
}),
},
}),
editorProps: {
attributes: {
class: 'ts-md',
role: 'textbox',
'aria-multiline': 'true',
'aria-label': this.label() || this.placeholder() || 'Texto',
},
handlePaste: (_view, event) => this.pasteImages(event),
},
onUpdate: ({ editor }) => {
this.written = markdownOf(editor);
this.value.set(this.written);
},
onTransaction: () => this.tick.update((n) => n + 1),
onFocus: () => this.focused.set(true),
onBlur: () => {
this.focused.set(false);
this.touch.emit();
},
});
}
private suggest(props: SuggestionProps<Mentionable>): void {
const caret = props.clientRect?.();
const box = this.root.nativeElement
.querySelector('.ts-rich')
?.getBoundingClientRect();
if (!props.items.length || !caret || !box) {
this.suggesting.set(null);
return;
}
this.suggesting.set({
items: props.items,
pick: (item) => props.command(item),
left: Math.max(0, caret.left - box.left),
top: caret.bottom - box.top + 4,
index: 0,
});
}
private suggestKey(event: KeyboardEvent): boolean {
const open = this.suggesting();
if (!open) return false;
const count = open.items.length;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const step = event.key === 'ArrowDown' ? 1 : -1;
this.suggesting.set({
...open,
index: (open.index + step + count) % count,
});
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
open.pick(open.items[open.index]);
return true;
}
if (event.key === 'Escape') {
this.suggesting.set(null);
return true;
}
return false;
}
/** Screenshots go up as attachments and come in where the caret is. */
private pasteImages(event: ClipboardEvent): boolean {
const upload = this.upload();
const files = Array.from(event.clipboardData?.files ?? []).filter((f) =>
f.type.startsWith('image/'),
);
if (!upload || !files.length) return false;
event.preventDefault();
for (const file of files) {
const alt = (file.name || 'captura').replace(/[[\]()\n]/g, ' ');
const sub = upload(file).subscribe((id) =>
this.editor
?.chain()
.focus()
.insertContent({
type: 'image',
attrs: { src: `attachment:${id}`, alt },
})
.run(),
);
this.destroyRef.onDestroy(() => sub.unsubscribe());
}
return true;
}
}

View 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![captura.png](attachment:abc-123)')).toContain(
'![captura.png](attachment:abc-123)',
);
});
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/);
});
});

View File

@@ -0,0 +1,164 @@
import { AnyExtension, Editor, Extension } from '@tiptap/core';
import { Image, ImageOptions } from '@tiptap/extension-image';
import { TaskItem, TaskList } from '@tiptap/extension-list';
import { Mention } from '@tiptap/extension-mention';
import { TableKit } from '@tiptap/extension-table';
import { Placeholder } from '@tiptap/extensions';
import { Markdown } from '@tiptap/markdown';
import { StarterKit } from '@tiptap/starter-kit';
import { SuggestionOptions } from '@tiptap/suggestion';
import { Observable, retry } from 'rxjs';
/** Fetches an attachment's bytes (with the session), by id. */
export type AttachmentLoader = (attachmentId: string) => Observable<Blob>;
/** Uploads a pasted image and answers its attachment id. */
export type ImageUploader = (file: File) => Observable<string>;
/** Someone who can be mentioned. */
export interface Mentionable {
id: string;
name: string;
}
/** `@[Ana García](<user id>)`: the API reads the id to tell that person. */
const MENTION = /^@\[([^\]\n]{1,80})\]\(([\w-]{1,64})\)/;
const ATTACHMENT = /^attachment:([\w-]{1,64})$/;
/**
* Mentions as this app stores them. TipTap's own Markdown for a mention is
* a shortcode; descriptions and comments keep `@[Name](id)`, which the API,
* the MCP and the e-mails already read.
*/
const TsMention = Mention.extend({
markdownTokenName: 'tsMention',
markdownTokenizer: {
name: 'tsMention',
level: 'inline',
start: (src: string) => src.indexOf('@['),
tokenize: (src: string) => {
const match = MENTION.exec(src);
return match
? { type: 'tsMention', raw: match[0], label: match[1], id: match[2] }
: undefined;
},
},
parseMarkdown: (token, helpers) =>
helpers.createNode('mention', { id: token['id'], label: token['label'] }),
renderMarkdown: (node) => {
const label = String(node.attrs?.['label'] ?? '')
.replace(/[[\]()\n]/g, ' ')
.trim();
return `@[${label}](${node.attrs?.['id']})`;
},
});
/**
* Images pasted into the text: `attachment:<id>` is a file of the ticket,
* which needs the session, so it is fetched with `loader` instead of being
* left to an `<img>`.
*/
const TsImage = Image.extend<
ImageOptions & { loader: AttachmentLoader | null }
>({
addOptions() {
return { ...(this.parent?.() as ImageOptions), loader: null };
},
addNodeView() {
return ({ node }) => {
const img = document.createElement('img');
img.alt = node.attrs['alt'] ?? '';
const src = String(node.attrs['src'] ?? '');
const id = ATTACHMENT.exec(src)?.[1];
if (!id) {
img.src = src;
return { dom: img };
}
img.dataset['attachment'] = id;
let url: string | null = null;
// A screenshot just pasted may not be readable yet: the API takes a
// moment to list a new attachment.
const sub = this.options
.loader?.(id)
.pipe(retry({ count: 4, delay: 750 }))
.subscribe({
next: (blob) => {
url = URL.createObjectURL(blob);
img.src = url;
},
error: () => (img.alt = `${img.alt} (no disponible)`),
});
return {
dom: img,
destroy: () => {
sub?.unsubscribe();
if (url) URL.revokeObjectURL(url);
},
};
};
},
});
export interface RichTextOptions {
placeholder?: () => string;
loader?: AttachmentLoader | null;
/** What `@` suggests; without it, mentions are kept but not offered. */
suggestion?: Omit<SuggestionOptions<Mentionable>, 'editor'> | null;
/** Ctrl/Cmd+Enter. */
onSubmit?: () => void;
}
/**
* Everything a description or a comment can hold, read from and written to
* the Markdown the app stores (GitHub's flavour): what was already written
* (tables, task lists, code, pasted images, mentions) survives an edit.
*/
export function richTextExtensions(
options: RichTextOptions = {},
): AnyExtension[] {
return [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4] },
// Markdown has no underline: it would be lost on save.
underline: false,
link: {
openOnClick: false,
autolink: true,
protocols: ['mailto'],
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
},
}),
TaskList,
TaskItem.configure({ nested: true }),
TableKit.configure({ table: { resizable: false } }),
TsImage.configure({ inline: true, loader: options.loader ?? null }),
TsMention.configure({
HTMLAttributes: { class: 'ts-mention' },
renderText: ({ node }) => `@${node.attrs['label']}`,
renderHTML: ({ options: o, node }) => [
'span',
{ ...o.HTMLAttributes, 'data-user': node.attrs['id'] },
`@${node.attrs['label']}`,
],
...(options.suggestion
? { suggestion: { char: '@', ...options.suggestion } }
: { suggestion: { char: '@', items: () => [] } }),
}),
Placeholder.configure({ placeholder: () => options.placeholder?.() ?? '' }),
Extension.create({
name: 'tsSubmit',
addKeyboardShortcuts: () => ({
'Mod-Enter': () => {
options.onSubmit?.();
return !!options.onSubmit;
},
}),
}),
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
];
}
/** The editor's content as the Markdown the app stores ('' when empty). */
export function markdownOf(editor: Editor): string {
return editor.isEmpty ? '' : editor.getMarkdown().trim();
}