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:
@@ -3,7 +3,7 @@
|
|||||||
## Qué resuelve
|
## Qué resuelve
|
||||||
|
|
||||||
| Necesidad | Cómo se resuelve |
|
| Necesidad | Cómo se resuelve |
|
||||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
| 🐛 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 |
|
| 🌍 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 |
|
| 🗑️ 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 |
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
| 🚀 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 |
|
| 📝 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 |
|
| 👥 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 |
|
| 💬 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 |
|
| 🔎 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 |
|
| 📋 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 |
|
| 🚦 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 |
|
||||||
|
|||||||
@@ -162,8 +162,9 @@ stateDiagram-v2
|
|||||||
que ser del mismo ticket. Quien escribió el comentario recibe un aviso (`REPLIED`). Un comentario borrado que tiene
|
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
|
respuestas se queda, sin texto (`deleted: true`), para que el hilo se siga entendiendo, y desaparece con su última
|
||||||
respuesta; no cuenta en `commentCount`.
|
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
|
- **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). Una imagen pegada se sube como adjunto y queda en el texto como
|
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
|
||||||
``: la subida responde `attachmentId`, y la web la descarga con la sesión de quien lee, así
|
``: la subida responde `attachmentId`, y la web la descarga con la sesión de quien lee, así
|
||||||
que sirve también en proyectos privados.
|
que sirve también en proyectos privados.
|
||||||
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
|
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ API repartida en `libs/web/*`.
|
|||||||
## 4.1 Librerías
|
## 4.1 Librerías
|
||||||
|
|
||||||
| Librería | Contenido |
|
| Librería | Contenido |
|
||||||
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` |
|
| `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/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/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` |
|
||||||
@@ -16,7 +16,7 @@ API repartida en `libs/web/*`.
|
|||||||
| `web/tracker/domain` | Modelos, enums, workflow y puertos (`ProjectsGateway`, `IssuesGateway`, `TrackerRealtimeGateway`…) |
|
| `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/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/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` |
|
| `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
|
## 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
|
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.
|
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 ``; 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
|
### Tiempo real y consistencia
|
||||||
|
|
||||||
- Cada store se suscribe solo a lo que muestra (`issueChanges(projectId)`, `commentChanges(issueId)`…).
|
- Cada store se suscribe solo a lo que muestra (`issueChanges(projectId)`, `commentChanges(issueId)`…).
|
||||||
|
|||||||
@@ -64,3 +64,4 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
|||||||
`taskstream: true` en un `PATCH`.
|
`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
|
- 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`.
|
depende de `lodash`.
|
||||||
|
- Descripciones y comentarios con editor visual (guardado en Markdown), con menciones y capturas pegadas.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -50,6 +50,7 @@ import {
|
|||||||
Workflow,
|
Workflow,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { IssueCardComponent } from '../ui/issue-card';
|
import { IssueCardComponent } from '../ui/issue-card';
|
||||||
|
import { RichEditorComponent } from '../ui/rich-editor';
|
||||||
import {
|
import {
|
||||||
options,
|
options,
|
||||||
pointOptions,
|
pointOptions,
|
||||||
@@ -97,6 +98,7 @@ const NOBODY = '__nobody__';
|
|||||||
PiSlideToggleComponent,
|
PiSlideToggleComponent,
|
||||||
PiTextFieldComponent,
|
PiTextFieldComponent,
|
||||||
PiTextareaComponent,
|
PiTextareaComponent,
|
||||||
|
RichEditorComponent,
|
||||||
PiLabelDirective,
|
PiLabelDirective,
|
||||||
],
|
],
|
||||||
providers: [provideBoardFacade()],
|
providers: [provideBoardFacade()],
|
||||||
@@ -337,15 +339,16 @@ const NOBODY = '__nobody__';
|
|||||||
<pi-text-field [formField]="f.title" [placeholder]="titlePlaceholder()">
|
<pi-text-field [formField]="f.title" [placeholder]="titlePlaceholder()">
|
||||||
<pi-label [isRequired]="true">Título</pi-label>
|
<pi-label [isRequired]="true">Título</pi-label>
|
||||||
</pi-text-field>
|
</pi-text-field>
|
||||||
<pi-textarea
|
<ts-rich-editor
|
||||||
|
label="Descripción"
|
||||||
[formField]="f.description"
|
[formField]="f.description"
|
||||||
[placeholder]="
|
[placeholder]="
|
||||||
template()?.description ??
|
template()?.description ??
|
||||||
'Pasos para reproducir, criterios de aceptación…'
|
'Pasos para reproducir, criterios de aceptación…'
|
||||||
"
|
"
|
||||||
>
|
[members]="mentionable()"
|
||||||
<pi-label>Descripción</pi-label>
|
[me]="me()"
|
||||||
</pi-textarea>
|
/>
|
||||||
@if (template(); as t) {
|
@if (template(); as t) {
|
||||||
<p class="ts-muted ts-template-hint">
|
<p class="ts-muted ts-template-hint">
|
||||||
Plantilla de {{ typeLabel() }}:
|
Plantilla de {{ typeLabel() }}:
|
||||||
@@ -473,10 +476,14 @@ export class BoardPage {
|
|||||||
|
|
||||||
protected readonly board = inject(BoardFacade);
|
protected readonly board = inject(BoardFacade);
|
||||||
protected readonly project = inject(ProjectFacade);
|
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 drawer = inject(DrawerService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
private readonly auth = inject(AuthFacade);
|
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 =
|
private readonly createForm =
|
||||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||||
private readonly reasonForm =
|
private readonly reasonForm =
|
||||||
|
|||||||
@@ -445,30 +445,6 @@ a.ts-key {
|
|||||||
margin: 0.5rem 0;
|
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).
|
// Code that mentions the ticket (from the Git integration).
|
||||||
.ts-dev {
|
.ts-dev {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -40,13 +40,10 @@ import {
|
|||||||
Attachment,
|
Attachment,
|
||||||
Comment,
|
Comment,
|
||||||
DevLink,
|
DevLink,
|
||||||
insertMention,
|
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
isTyping,
|
isTyping,
|
||||||
matches,
|
|
||||||
mentionQuery,
|
|
||||||
POINT_SCALE,
|
POINT_SCALE,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
@@ -68,9 +65,8 @@ import {
|
|||||||
AttachmentLoader,
|
AttachmentLoader,
|
||||||
ImageUploader,
|
ImageUploader,
|
||||||
MarkdownComponent,
|
MarkdownComponent,
|
||||||
MarkdownHintComponent,
|
|
||||||
PasteImagesDirective,
|
|
||||||
} from '../ui/markdown';
|
} from '../ui/markdown';
|
||||||
|
import { RichEditorComponent } from '../ui/rich-editor';
|
||||||
|
|
||||||
const NONE = '';
|
const NONE = '';
|
||||||
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
|
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
|
||||||
@@ -83,8 +79,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
BlockedComponent,
|
BlockedComponent,
|
||||||
ReactionsComponent,
|
ReactionsComponent,
|
||||||
MarkdownComponent,
|
MarkdownComponent,
|
||||||
MarkdownHintComponent,
|
RichEditorComponent,
|
||||||
PasteImagesDirective,
|
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
FormField,
|
FormField,
|
||||||
@@ -669,46 +664,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
(submit)="$event.preventDefault(); reply(thread.comment)"
|
(submit)="$event.preventDefault(); reply(thread.comment)"
|
||||||
novalidate
|
novalidate
|
||||||
>
|
>
|
||||||
<pi-textarea
|
<ts-rich-editor
|
||||||
[placeholder]="
|
[placeholder]="
|
||||||
'Responde a ' +
|
'Responde a ' + thread.comment.author.name + '…'
|
||||||
thread.comment.author.name +
|
|
||||||
'… Escribe @ para mencionar a alguien.'
|
|
||||||
"
|
"
|
||||||
[(value)]="replyBody"
|
[(value)]="replyBody"
|
||||||
[tsPasteImages]="pasteImage"
|
[members]="mentionable()"
|
||||||
/>
|
|
||||||
<ts-markdown-hint
|
|
||||||
[text]="replyBody()"
|
|
||||||
[me]="facade.me()"
|
[me]="facade.me()"
|
||||||
|
[upload]="pasteImage"
|
||||||
[loader]="loadImage"
|
[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">
|
<div class="ts-reply__actions">
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -748,37 +714,15 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
(submit)="$event.preventDefault(); addComment()"
|
(submit)="$event.preventDefault(); addComment()"
|
||||||
novalidate
|
novalidate
|
||||||
>
|
>
|
||||||
<pi-textarea
|
<ts-rich-editor
|
||||||
placeholder="Deja una decisión, una explicación, un paso para reproducir… Escribe @ para mencionar a alguien."
|
placeholder="Deja una decisión, una explicación, un paso para reproducir…"
|
||||||
[(value)]="commentBody"
|
[(value)]="commentBody"
|
||||||
[tsPasteImages]="pasteImage"
|
[members]="mentionable()"
|
||||||
/>
|
|
||||||
<ts-markdown-hint
|
|
||||||
[text]="commentBody()"
|
|
||||||
[me]="facade.me()"
|
[me]="facade.me()"
|
||||||
|
[upload]="pasteImage"
|
||||||
[loader]="loadImage"
|
[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
|
<pi-file-upload
|
||||||
label="Adjuntar al comentario"
|
label="Adjuntar al comentario"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
@@ -964,17 +908,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
<pi-text-field [formField]="edit.title">
|
<pi-text-field [formField]="edit.title">
|
||||||
<pi-label [isRequired]="true">Título</pi-label>
|
<pi-label [isRequired]="true">Título</pi-label>
|
||||||
</pi-text-field>
|
</pi-text-field>
|
||||||
<pi-textarea
|
<ts-rich-editor
|
||||||
|
label="Descripción"
|
||||||
[formField]="edit.description"
|
[formField]="edit.description"
|
||||||
[tsPasteImages]="pasteImage"
|
[members]="mentionable()"
|
||||||
>
|
|
||||||
<pi-label>Descripción</pi-label>
|
|
||||||
</pi-textarea>
|
|
||||||
<ts-markdown-hint
|
|
||||||
[text]="editModel().description"
|
|
||||||
[me]="facade.me()"
|
[me]="facade.me()"
|
||||||
|
[upload]="pasteImage"
|
||||||
[loader]="loadImage"
|
[loader]="loadImage"
|
||||||
[images]="true"
|
(submitted)="saveEdit()"
|
||||||
/>
|
/>
|
||||||
<pi-text-field [formField]="edit.labels" placeholder="safari, checkout">
|
<pi-text-field [formField]="edit.labels" placeholder="safari, checkout">
|
||||||
<pi-label>Etiquetas (separadas por comas)</pi-label>
|
<pi-label>Etiquetas (separadas por comas)</pi-label>
|
||||||
@@ -1001,15 +942,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
(submit)="$event.preventDefault(); saveComment()"
|
(submit)="$event.preventDefault(); saveComment()"
|
||||||
novalidate
|
novalidate
|
||||||
>
|
>
|
||||||
<pi-textarea
|
<ts-rich-editor
|
||||||
[formField]="commentEdit.body"
|
[formField]="commentEdit.body"
|
||||||
[tsPasteImages]="pasteImage"
|
[members]="mentionable()"
|
||||||
/>
|
|
||||||
<ts-markdown-hint
|
|
||||||
[text]="commentEditModel().body"
|
|
||||||
[me]="facade.me()"
|
[me]="facade.me()"
|
||||||
|
[upload]="pasteImage"
|
||||||
[loader]="loadImage"
|
[loader]="loadImage"
|
||||||
[images]="true"
|
(submitted)="saveComment()"
|
||||||
/>
|
/>
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
size="w100"
|
size="w100"
|
||||||
@@ -1167,13 +1106,9 @@ export class IssuePage {
|
|||||||
this.facade.fileById(id);
|
this.facade.fileById(id);
|
||||||
protected readonly pasteImage: ImageUploader = (file) =>
|
protected readonly pasteImage: ImageUploader = (file) =>
|
||||||
this.facade.uploadImage(file);
|
this.facade.uploadImage(file);
|
||||||
/** Members matching what is typed after an `@` at the end of the comment. */
|
/** Who `@` offers in what is written here. */
|
||||||
protected readonly mentionOptions = computed(() =>
|
protected readonly mentionable = computed(() =>
|
||||||
this.membersMatching(this.commentBody()),
|
this.project.members().map((m) => m.user),
|
||||||
);
|
|
||||||
/** The same, for the answer being written. */
|
|
||||||
protected readonly replyMentionOptions = computed(() =>
|
|
||||||
this.membersMatching(this.replyBody()),
|
|
||||||
);
|
);
|
||||||
/** Pull requests first, then the newest. */
|
/** Pull requests first, then the newest. */
|
||||||
protected readonly devLinks = computed(() =>
|
protected readonly devLinks = computed(() =>
|
||||||
@@ -1382,27 +1317,6 @@ export class IssuePage {
|
|||||||
this.replyingTo.set(null);
|
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 {
|
protected attach(): void {
|
||||||
this.facade.attach(this.pendingFiles());
|
this.facade.attach(this.pendingFiles());
|
||||||
this.pendingFiles.set([]);
|
this.pendingFiles.set([]);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
PiActionButtonComponent,
|
PiActionButtonComponent,
|
||||||
PiLabelDirective,
|
PiLabelDirective,
|
||||||
PiNoteComponent,
|
PiNoteComponent,
|
||||||
PiTextareaComponent,
|
|
||||||
PiTextFieldComponent,
|
PiTextFieldComponent,
|
||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +17,7 @@ import {
|
|||||||
ProposeFacade,
|
ProposeFacade,
|
||||||
} from '@task-stream/web/tracker/application';
|
} from '@task-stream/web/tracker/application';
|
||||||
import { ProposalResult } from '@task-stream/web/tracker/domain';
|
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
|
* /p/:key/proponer — anyone, without an account, reports a bug or proposes
|
||||||
@@ -30,11 +29,10 @@ import { MarkdownHintComponent } from '../ui/markdown';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
RouterLink,
|
RouterLink,
|
||||||
MarkdownHintComponent,
|
RichEditorComponent,
|
||||||
PiActionButtonComponent,
|
PiActionButtonComponent,
|
||||||
PiLabelDirective,
|
PiLabelDirective,
|
||||||
PiNoteComponent,
|
PiNoteComponent,
|
||||||
PiTextareaComponent,
|
|
||||||
PiTextFieldComponent,
|
PiTextFieldComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
@@ -114,17 +112,16 @@ import { MarkdownHintComponent } from '../ui/markdown';
|
|||||||
>
|
>
|
||||||
<pi-label [isRequired]="true">Título</pi-label>
|
<pi-label [isRequired]="true">Título</pi-label>
|
||||||
</pi-text-field>
|
</pi-text-field>
|
||||||
<pi-textarea
|
<ts-rich-editor
|
||||||
|
label="Detalles"
|
||||||
[placeholder]="
|
[placeholder]="
|
||||||
kind() === 'BUG'
|
kind() === 'BUG'
|
||||||
? 'Qué hiciste, qué esperabas y qué pasó. El navegador o el móvil ayudan.'
|
? 'Qué hiciste, qué esperabas y qué pasó. El navegador o el móvil ayudan.'
|
||||||
: 'Qué te gustaría y para qué te serviría.'
|
: 'Qué te gustaría y para qué te serviría.'
|
||||||
"
|
"
|
||||||
[(value)]="description"
|
[(value)]="description"
|
||||||
>
|
(submitted)="submit(p.key)"
|
||||||
<pi-label>Detalles</pi-label>
|
/>
|
||||||
</pi-textarea>
|
|
||||||
<ts-markdown-hint [text]="description()" />
|
|
||||||
<pi-text-field placeholder="Opcional" [(value)]="author">
|
<pi-text-field placeholder="Opcional" [(value)]="author">
|
||||||
<pi-label>Tu nombre o alias</pi-label>
|
<pi-label>Tu nombre o alias</pi-label>
|
||||||
</pi-text-field>
|
</pi-text-field>
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
Directive,
|
|
||||||
effect,
|
effect,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
HostListener,
|
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
signal,
|
|
||||||
untracked,
|
untracked,
|
||||||
ViewEncapsulation,
|
ViewEncapsulation,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -17,7 +14,7 @@ import { DomSanitizer } from '@angular/platform-browser';
|
|||||||
import { mentionSegments } from '@task-stream/web/tracker/domain';
|
import { mentionSegments } from '@task-stream/web/tracker/domain';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { Observable } from 'rxjs';
|
import type { AttachmentLoader } from './rich-text';
|
||||||
|
|
||||||
/** Images pasted into a text are stored as attachments: ``. */
|
/** Images pasted into a text are stored as attachments: ``. */
|
||||||
const ATTACHMENT = /^attachment:([\w-]{1,64})$/;
|
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, ImageUploader } from './rich-text';
|
||||||
export type AttachmentLoader = (attachmentId: string) => Observable<Blob>;
|
|
||||||
|
|
||||||
/**
|
/** How formatted text looks, read (`ts-markdown`) or being written (`ts-rich-editor`). */
|
||||||
* Formatted text: Markdown rendered safely. Pasted images (`attachment:`)
|
export const MARKDOWN_STYLES = `
|
||||||
* 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: `
|
|
||||||
.ts-md {
|
.ts-md {
|
||||||
display: block;
|
display: block;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -184,7 +171,16 @@ export type AttachmentLoader = (attachmentId: string) => Observable<Blob>;
|
|||||||
ol {
|
ol {
|
||||||
padding-left: 1.375rem;
|
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;
|
list-style: none;
|
||||||
margin-left: -1.375rem;
|
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);
|
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 {
|
export class MarkdownComponent {
|
||||||
readonly text = input<string | null | undefined>('');
|
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 `` 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\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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
653
libs/web/tracker/presenters/src/lib/ui/rich-editor.ts
Normal file
653
libs/web/tracker/presenters/src/lib/ui/rich-editor.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
libs/web/tracker/presenters/src/lib/ui/rich-text.spec.ts
Normal file
88
libs/web/tracker/presenters/src/lib/ui/rich-text.spec.ts
Normal 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')).toContain(
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
164
libs/web/tracker/presenters/src/lib/ui/rich-text.ts
Normal file
164
libs/web/tracker/presenters/src/lib/ui/rich-text.ts
Normal 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();
|
||||||
|
}
|
||||||
656
package-lock.json
generated
656
package-lock.json
generated
@@ -39,6 +39,16 @@
|
|||||||
"@ngx-translate/core": "^18.0.0",
|
"@ngx-translate/core": "^18.0.0",
|
||||||
"@ngx-translate/http-loader": "^18.0.0",
|
"@ngx-translate/http-loader": "^18.0.0",
|
||||||
"@pi/ui": "^0.0.5",
|
"@pi/ui": "^0.0.5",
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/extension-image": "3.31.3",
|
||||||
|
"@tiptap/extension-list": "3.31.3",
|
||||||
|
"@tiptap/extension-mention": "3.31.3",
|
||||||
|
"@tiptap/extension-table": "3.31.3",
|
||||||
|
"@tiptap/extensions": "3.31.3",
|
||||||
|
"@tiptap/markdown": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3",
|
||||||
|
"@tiptap/starter-kit": "3.31.3",
|
||||||
|
"@tiptap/suggestion": "3.31.3",
|
||||||
"apollo-angular": "^14.2.0",
|
"apollo-angular": "^14.2.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -6019,6 +6029,34 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/core": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/utils": "^0.2.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/dom": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.8.0",
|
||||||
|
"@floating-ui/utils": "^0.2.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/utils": {
|
||||||
|
"version": "0.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||||
|
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@graphql-codegen/add": {
|
"node_modules/@graphql-codegen/add": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-7.1.1.tgz",
|
||||||
@@ -14016,6 +14054,461 @@
|
|||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/core": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bold": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code-block": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-document": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-dropcursor": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-hard-break": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-heading": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-image": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-italic": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-keymap": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-mention": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-ygOPc3nQijSMUTbEdMeng12P1szk3znubA9xNi84f31VnR18VjmU0we8SIOSSVwDfm1GppSjRER2qHmX/wGIIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3",
|
||||||
|
"@tiptap/suggestion": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-paragraph": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-strike": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-table": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-underline": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extensions": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/markdown": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-rBbYSxasseUoaBo15C8Yoaqafo7mJJzxwAwV9Z1OpLBvOroW9nQ0EbOdqaSYRw3/d9pP71B/3LNSzKyfyy+dpQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"marked": "^17.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/markdown/node_modules/marked": {
|
||||||
|
"version": "17.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz",
|
||||||
|
"integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/pm": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-changeset": "^2.4.1",
|
||||||
|
"prosemirror-commands": "^1.7.1",
|
||||||
|
"prosemirror-dropcursor": "^1.8.2",
|
||||||
|
"prosemirror-gapcursor": "^1.4.1",
|
||||||
|
"prosemirror-history": "^1.5.0",
|
||||||
|
"prosemirror-inputrules": "^1.5.1",
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.11",
|
||||||
|
"prosemirror-schema-list": "^1.5.1",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-tables": "^1.8.5",
|
||||||
|
"prosemirror-transform": "^1.12.0",
|
||||||
|
"prosemirror-view": "^1.42.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/starter-kit": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/extension-blockquote": "3.31.3",
|
||||||
|
"@tiptap/extension-bold": "3.31.3",
|
||||||
|
"@tiptap/extension-bullet-list": "3.31.3",
|
||||||
|
"@tiptap/extension-code": "3.31.3",
|
||||||
|
"@tiptap/extension-code-block": "3.31.3",
|
||||||
|
"@tiptap/extension-document": "3.31.3",
|
||||||
|
"@tiptap/extension-dropcursor": "3.31.3",
|
||||||
|
"@tiptap/extension-gapcursor": "3.31.3",
|
||||||
|
"@tiptap/extension-hard-break": "3.31.3",
|
||||||
|
"@tiptap/extension-heading": "3.31.3",
|
||||||
|
"@tiptap/extension-horizontal-rule": "3.31.3",
|
||||||
|
"@tiptap/extension-italic": "3.31.3",
|
||||||
|
"@tiptap/extension-link": "3.31.3",
|
||||||
|
"@tiptap/extension-list": "3.31.3",
|
||||||
|
"@tiptap/extension-list-item": "3.31.3",
|
||||||
|
"@tiptap/extension-list-keymap": "3.31.3",
|
||||||
|
"@tiptap/extension-ordered-list": "3.31.3",
|
||||||
|
"@tiptap/extension-paragraph": "3.31.3",
|
||||||
|
"@tiptap/extension-strike": "3.31.3",
|
||||||
|
"@tiptap/extension-text": "3.31.3",
|
||||||
|
"@tiptap/extension-underline": "3.31.3",
|
||||||
|
"@tiptap/extensions": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/suggestion": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-z1OQ/Yx6seMZehi1AcmNkyJ8qkHQzybArmCyRdmreS4YL9C4bPMBe5lbMfFsArYs+KD5JyHwps5j7J/YE5BIuw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0",
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tokenizer/inflate": {
|
"node_modules/@tokenizer/inflate": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||||
@@ -23949,6 +24442,12 @@
|
|||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/listr2": {
|
"node_modules/listr2": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/listr2/-/listr2-11.0.0.tgz",
|
||||||
@@ -25739,6 +26238,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/oxc-parser": {
|
"node_modules/oxc-parser": {
|
||||||
"version": "0.142.0",
|
"version": "0.142.0",
|
||||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.142.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.142.0.tgz",
|
||||||
@@ -27258,6 +27763,145 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-changeset": {
|
||||||
|
"version": "2.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
|
||||||
|
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
|
||||||
|
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-dropcursor": {
|
||||||
|
"version": "1.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
|
||||||
|
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0",
|
||||||
|
"prosemirror-view": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-gapcursor": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.0.0",
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-inputrules": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||||
|
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.42.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
|
||||||
|
"integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.25.12",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.8",
|
"version": "2.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||||
@@ -27723,6 +28367,12 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
@@ -31706,6 +32356,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/w3c-xmlserializer": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -35,6 +35,16 @@
|
|||||||
"@ngx-translate/core": "^18.0.0",
|
"@ngx-translate/core": "^18.0.0",
|
||||||
"@ngx-translate/http-loader": "^18.0.0",
|
"@ngx-translate/http-loader": "^18.0.0",
|
||||||
"@pi/ui": "^0.0.5",
|
"@pi/ui": "^0.0.5",
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/extension-image": "3.31.3",
|
||||||
|
"@tiptap/extension-list": "3.31.3",
|
||||||
|
"@tiptap/extension-mention": "3.31.3",
|
||||||
|
"@tiptap/extension-table": "3.31.3",
|
||||||
|
"@tiptap/extensions": "3.31.3",
|
||||||
|
"@tiptap/markdown": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3",
|
||||||
|
"@tiptap/starter-kit": "3.31.3",
|
||||||
|
"@tiptap/suggestion": "3.31.3",
|
||||||
"apollo-angular": "^14.2.0",
|
"apollo-angular": "^14.2.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user