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()) { @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