diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index c79a6d7..2aaf292 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -2,47 +2,47 @@ ## Qué resuelve -| Necesidad | Cómo se resuelve | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | -| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista | -| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre | -| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | -| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | -| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | -| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | -| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | -| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar | -| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | -| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…) y `@menciones`; el autor edita, el admin modera | -| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | -| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar | -| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | -| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | -| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | -| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics | -| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | -| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | -| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | -| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | -| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | -| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere | -| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) | -| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | -| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo | -| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo | -| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence | -| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | -| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» | -| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | -| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo | -| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint | -| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío | -| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear | -| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | -| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | -| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | -| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | +| Necesidad | Cómo se resuelve | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | +| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista | +| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre | +| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | +| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | +| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | +| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | +| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | +| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar | +| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | +| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…) y `@menciones`; el autor edita, el admin modera | +| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | +| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar | +| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | +| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | +| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | +| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics | +| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | +| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | +| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | +| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | +| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | +| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere | +| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) | +| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | +| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo | +| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo | +| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence | +| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | +| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» | +| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | +| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo | +| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint | +| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío | +| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear | +| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | +| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | +| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | +| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon** ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por diff --git a/docs/03-api.md b/docs/03-api.md index 2c8b136..134ae34 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -162,8 +162,9 @@ stateDiagram-v2 que ser del mismo ticket. Quien escribió el comentario recibe un aviso (`REPLIED`). Un comentario borrado que tiene respuestas se queda, sin texto (`deleted: true`), para que el hilo se siga entendiendo, y desaparece con su última respuesta; no cuenta en `commentCount`. -- **Formato**: descripciones, comentarios y notas de versión se escriben en Markdown; la API los guarda tal cual y la web - los muestra limpiando el HTML (DOMPurify). Una imagen pegada se sube como adjunto y queda en el texto como +- **Formato**: descripciones, comentarios y notas de versión se guardan en Markdown; la API los guarda tal cual y la web + los muestra limpiando el HTML (DOMPurify). En la web se escriben con un editor visual que lee y escribe ese mismo + Markdown, así que un cliente de la API (o el MCP) sigue mandando Markdown. Una imagen pegada se sube como adjunto y queda en el texto como `![nombre](attachment:)`: la subida responde `attachmentId`, y la web la descarga con la sesión de quien lee, así que sirve también en proyectos privados. - **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`, diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index d1053e1..443e0b6 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -5,18 +5,18 @@ API repartida en `libs/web/*`. ## 4.1 Librerías -| Librería | Contenido | -| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` | -| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService`, `DrawerService`, `AppShellComponent` | -| `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` | -| `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` | -| `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` | -| `web/identity/presenters` | Página de login, callback de Pireon, `authGuard` / `guestGuard`, menú de usuario | -| `web/tracker/domain` | Modelos, enums, workflow y puertos (`ProjectsGateway`, `IssuesGateway`, `TrackerRealtimeGateway`…) | -| `web/tracker/application` | Facades `ProjectsFacade`, `ProjectFacade`, `BoardFacade`, `IssueFacade` y puerto `Notifications`; por dentro, stores y eventos | -| `web/tracker/infrastructure` | Adaptadores GraphQL con operaciones tipadas (codegen) | -| `web/tracker/presenters` | Páginas: proyectos, tablero, lista, ticket, propuestas, releases, seguimiento, actividad y miembros; `ts-markdown` (texto con formato, saneado con DOMPurify), `ts-bulk-bar`, `ts-vote` | +| Librería | Contenido | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` | +| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService`, `DrawerService`, `AppShellComponent` | +| `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` | +| `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` | +| `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` | +| `web/identity/presenters` | Página de login, callback de Pireon, `authGuard` / `guestGuard`, menú de usuario | +| `web/tracker/domain` | Modelos, enums, workflow y puertos (`ProjectsGateway`, `IssuesGateway`, `TrackerRealtimeGateway`…) | +| `web/tracker/application` | Facades `ProjectsFacade`, `ProjectFacade`, `BoardFacade`, `IssueFacade` y puerto `Notifications`; por dentro, stores y eventos | +| `web/tracker/infrastructure` | Adaptadores GraphQL con operaciones tipadas (codegen) | +| `web/tracker/presenters` | Páginas: proyectos, tablero, lista, ticket, propuestas, releases, seguimiento, actividad y miembros; `ts-markdown` (texto con formato, saneado con DOMPurify), `ts-rich-editor`, `ts-bulk-bar`, `ts-vote` | ## 4.2 Estado: NgRx Signals con eventos @@ -127,6 +127,23 @@ hijas de `/p/:key` reciben la misma `ProjectFacade`. Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL. +### Texto con formato + +Descripciones y comentarios se escriben con `ts-rich-editor`, un editor visual sobre +[TipTap](https://tiptap.dev) (ProseMirror) que **lee y escribe Markdown**, que es lo que guarda la API: + +- Barra con negrita, cursiva, tachado, código, título, listas, tareas, cita, bloque de código y enlace; Ctrl/Cmd+Enter + envía. +- `@` sugiere a los miembros del proyecto y escribe la mención como siempre, `@[Nombre](id)` (un lector propio en + `rich-text.ts`; la de TipTap usa otro formato). +- Pegar una captura la sube como adjunto y la deja como `![nombre](attachment:)`; dentro del editor se descarga con + la sesión, reintentando unos instantes porque la API tarda en listar el adjunto nuevo. +- Entiende también tablas, para que editar un texto antiguo no las pierda (`rich-text.spec.ts` comprueba la ida y + vuelta). +- Funciona con `[(value)]` y con `[formField]` de los formularios de señales. +- Va en un trozo aparte (unos 150 kB comprimido) que solo se descarga en el ticket, el tablero y el formulario de + propuestas. + ### Tiempo real y consistencia - Cada store se suscribe solo a lo que muestra (`issueChanges(projectId)`, `commentChanges(issueId)`…). diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 9694fd2..b0fe257 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -64,3 +64,4 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi `taskstream: true` en un `PATCH`. - Paquete inicial de la web de 2,13 MB a 1,88 MB con `@pi/ui` 0.0.4, que ya no registra Chart.js al importarse ni depende de `lodash`. +- Descripciones y comentarios con editor visual (guardado en Markdown), con menciones y capturas pegadas. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 703d25e..9672563 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index dccfd6e..b12f809 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index 04d3bcd..7cd417d 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -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__'; Título - - Descripción - + [members]="mentionable()" + [me]="me()" + /> @if (template(); as t) {

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>('createForm'); private readonly reasonForm = diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss index 9972661..f33b3c8 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -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; diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index 02941ca..090d2dc 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -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 > - - - @if (replyMentionOptions().length) { -

- @for ( - member of replyMentionOptions(); - track member.id - ) { - - } -
- }
- - - @if (mentionOptions().length) { -
- @for (member of mentionOptions(); track member.id) { - - } -
- } Título - - Descripción - - Etiquetas (separadas por comas) @@ -1001,15 +942,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; (submit)="$event.preventDefault(); saveComment()" novalidate > - - 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([]); diff --git a/libs/web/tracker/presenters/src/lib/pages/propose.page.ts b/libs/web/tracker/presenters/src/lib/pages/propose.page.ts index a46246c..6e21c21 100644 --- a/libs/web/tracker/presenters/src/lib/pages/propose.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/propose.page.ts @@ -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'; > Título - - Detalles - - + (submitted)="submit(p.key)" + /> Tu nombre o alias diff --git a/libs/web/tracker/presenters/src/lib/ui/markdown.ts b/libs/web/tracker/presenters/src/lib/ui/markdown.ts index 1e8003f..4f8a80c 100644 --- a/libs/web/tracker/presenters/src/lib/ui/markdown.ts +++ b/libs/web/tracker/presenters/src/lib/ui/markdown.ts @@ -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:)`. */ 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; +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 `` cannot send it. - */ -@Component({ - selector: 'ts-markdown', - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, - template: '
', - 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; 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; 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 `` cannot send it. + */ +@Component({ + selector: 'ts-markdown', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: '
', + styles: MARKDOWN_STYLES, }) export class MarkdownComponent { readonly text = input(''); @@ -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: ` -
- Admite Markdown: **negrita**, _cursiva_, código, listas y - enlaces{{ images() ? '; pega capturas con Ctrl+V' : '' }}. - -
- @if (open() && text()?.trim()) { - - } - `, - 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(''); - readonly me = input(null); - readonly loader = input(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; - -/** - * On a textarea (or a component wrapping one): pasting an image uploads it - * and writes `![name](attachment:)` 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(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()); - } - } -} diff --git a/libs/web/tracker/presenters/src/lib/ui/rich-editor.ts b/libs/web/tracker/presenters/src/lib/ui/rich-editor.ts new file mode 100644 index 0000000..4152d91 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/rich-editor.ts @@ -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()) { + {{ 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