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>
654 lines
19 KiB
TypeScript
654 lines
19 KiB
TypeScript
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;
|
|
}
|
|
}
|