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()) {
{{ label() }}
}
@if (!disabled()) {
@for (tool of tools; track $index) {
@if (tool === 'gap') {
} @else {
}
}
@if (linking()) {
}
}
@if (suggesting(); as s) {
@for (item of s.items; track item.id; let i = $index) {
}
}
@if (hint()) {
{{ hint() }}
}
`,
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