From 0de8d9218f67aa69b89be8445ac0a2af0414b9a4 Mon Sep 17 00:00:00 2001 From: Tacx <12997062+tacxou@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:25:29 +0200 Subject: [PATCH] feat(advanced): add rich WYSIWYG features and AI SDK assistant Extend the UEditor-based renderer with tables, task lists, alignment, colors, highlights, a link panel, find & replace, source mode, preview, an opt-in text bubble and an opt-in slash palette. Add an optional AI writing assistant built around a thin transport that targets a host AI SDK backend (UI message stream, text stream or JSON). Results stream into a detached panel and only reach the document after explicit acceptance; requests are abortable. No AI SDK or provider package is added, and AI stays inert unless ai.enabled is true. --- README.md | 41 +- docs/.vitepress/config.ts | 2 + docs/en/guide/options/string.md | 10 +- docs/en/guide/playground-examples.md | 1 + docs/en/guide/wysiwyg-ai.md | 207 +++++++++ docs/guide/options/string.md | 12 +- docs/guide/playground-examples.md | 1 + docs/guide/wysiwyg-ai.md | 209 +++++++++ package.json | 5 + playground/docs-api/props.ts | 27 +- playground/examples/items/nuxt-wysiwyg-ai.ts | 99 +++++ src/advanced/index.ts | 29 ++ src/advanced/wysiwyg.vue | 303 +++++++++++-- src/advanced/wysiwyg/WysiwygFindReplace.vue | 106 +++++ src/advanced/wysiwyg/WysiwygLinkPanel.vue | 69 +++ src/advanced/wysiwyg/WysiwygSourceEditor.vue | 67 +++ src/advanced/wysiwyg/ai/WysiwygAiResult.vue | 146 +++++++ src/advanced/wysiwyg/ai/actions.ts | 168 ++++++++ src/advanced/wysiwyg/ai/context.ts | 145 +++++++ src/advanced/wysiwyg/ai/httpTransport.ts | 144 +++++++ src/advanced/wysiwyg/ai/index.ts | 51 +++ src/advanced/wysiwyg/ai/operations.ts | 148 +++++++ src/advanced/wysiwyg/ai/options.ts | 80 ++++ src/advanced/wysiwyg/ai/sanitize.ts | 135 ++++++ src/advanced/wysiwyg/ai/stream.ts | 140 ++++++ src/advanced/wysiwyg/ai/tools.ts | 163 +++++++ src/advanced/wysiwyg/ai/types.ts | 159 +++++++ src/advanced/wysiwyg/ai/useWysiwygAi.ts | 285 ++++++++++++ src/advanced/wysiwyg/colors.ts | 27 ++ src/advanced/wysiwyg/extensions.ts | 44 ++ src/advanced/wysiwyg/features.ts | 55 +++ src/advanced/wysiwyg/handlers.ts | 175 ++++++++ src/advanced/wysiwyg/search.ts | 195 +++++++++ src/advanced/wysiwyg/slash/commands.ts | 72 ++++ src/advanced/wysiwyg/source.ts | 31 ++ src/advanced/wysiwyg/styles.ts | 101 +++++ src/advanced/wysiwyg/toolbar.ts | 165 +++++++ src/advanced/wysiwygOptions.ts | 45 +- tests/advanced/wysiwygAi.test.ts | 366 ++++++++++++++++ tests/advanced/wysiwygAiOptions.test.ts | 171 ++++++++ tests/advanced/wysiwygAiTransport.test.ts | 228 ++++++++++ tests/advanced/wysiwygEditorHarness.ts | 29 ++ tests/advanced/wysiwygFeatures.test.ts | 432 +++++++++++++++++++ tests/advanced/wysiwygOptions.test.ts | 5 +- yarn.lock | 20 + 45 files changed, 5073 insertions(+), 40 deletions(-) create mode 100644 docs/en/guide/wysiwyg-ai.md create mode 100644 docs/guide/wysiwyg-ai.md create mode 100644 playground/examples/items/nuxt-wysiwyg-ai.ts create mode 100644 src/advanced/wysiwyg/WysiwygFindReplace.vue create mode 100644 src/advanced/wysiwyg/WysiwygLinkPanel.vue create mode 100644 src/advanced/wysiwyg/WysiwygSourceEditor.vue create mode 100644 src/advanced/wysiwyg/ai/WysiwygAiResult.vue create mode 100644 src/advanced/wysiwyg/ai/actions.ts create mode 100644 src/advanced/wysiwyg/ai/context.ts create mode 100644 src/advanced/wysiwyg/ai/httpTransport.ts create mode 100644 src/advanced/wysiwyg/ai/index.ts create mode 100644 src/advanced/wysiwyg/ai/operations.ts create mode 100644 src/advanced/wysiwyg/ai/options.ts create mode 100644 src/advanced/wysiwyg/ai/sanitize.ts create mode 100644 src/advanced/wysiwyg/ai/stream.ts create mode 100644 src/advanced/wysiwyg/ai/tools.ts create mode 100644 src/advanced/wysiwyg/ai/types.ts create mode 100644 src/advanced/wysiwyg/ai/useWysiwygAi.ts create mode 100644 src/advanced/wysiwyg/colors.ts create mode 100644 src/advanced/wysiwyg/extensions.ts create mode 100644 src/advanced/wysiwyg/features.ts create mode 100644 src/advanced/wysiwyg/handlers.ts create mode 100644 src/advanced/wysiwyg/search.ts create mode 100644 src/advanced/wysiwyg/slash/commands.ts create mode 100644 src/advanced/wysiwyg/source.ts create mode 100644 src/advanced/wysiwyg/styles.ts create mode 100644 src/advanced/wysiwyg/toolbar.ts create mode 100644 tests/advanced/wysiwygAi.test.ts create mode 100644 tests/advanced/wysiwygAiOptions.test.ts create mode 100644 tests/advanced/wysiwygAiTransport.test.ts create mode 100644 tests/advanced/wysiwygEditorHarness.ts create mode 100644 tests/advanced/wysiwygFeatures.test.ts diff --git a/README.md b/README.md index eac244a..53260a9 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,10 @@ WYSIWYG options (`options.wysiwyg: true`): | `minHeight` / `padding` / `blockSpacing` / `editorClass` | spacing overrides | | `debounce` | `onChange` debounce in ms (default `300`) | | `image` | `false` to disable, or `{ upload, accept, maxSize, resize, bubble, … }` | -| `extensions` / `handlers` | extra TipTap extensions / Nuxt UI handlers | +| `extensions` / `handlers` | extra TipTap extensions / Nuxt UI handlers (host versions win) | +| `features` | tables, task lists, alignment, colors, highlight, link panel, find & replace, source mode, preview — all on; `false` or `{ table: false, … }` to opt out | +| `bubbleMenu` / `slashCommands` | formatting bubble on selection / `/` palette (default `false`) | +| `ai` | optional AI assistant, inert unless `ai.enabled === true` | ```ts options: { @@ -101,6 +104,42 @@ options: { }, } ``` + +#### WYSIWYG AI assistant + +AI actions (improve, fix grammar, shorter, summarize, translate, continue, custom +instruction…) are exposed in the selection bubble, the toolbar and `/ai`. Results stream into +a detached panel and only reach the document after *Replace* / *Insert* / *Insert below*; +*Stop* aborts the request. + +```ts +options: { + wysiwyg: true, + ai: { enabled: true, endpoint: '/api/ai/editor', actions: ['improve', 'shorter', 'translate'] }, +} +``` + +The package ships no AI SDK or provider dependency and never sees API keys: the endpoint is +the application's own route, implemented with [AI SDK](https://ai-sdk.dev/) and any provider. + +```ts +// server/api/ai/editor.post.ts (Nuxt / Nitro) +import { streamText } from 'ai' +import { openai } from '@ai-sdk/openai' // or anthropic, google, mistral, xai… + +export default defineEventHandler(async (event) => { + const request = await readBody(event) // WysiwygAiRequest + { stream } + const result = streamText({ + model: openai(process.env.AI_MODEL ?? 'gpt-5-mini'), + system: 'You are a writing assistant inside a rich-text editor. Return only the proposed text.', + prompt: `${request.instruction ?? request.action}\n\n${request.selectedText ?? request.textBeforeSelection}`, + }) + return result.toUIMessageStreamResponse() // toTextStreamResponse() and JSON also work +}) +``` + +Advanced setups pass `ai.transport` (`WysiwygAiTransport`, or `createWysiwygAiHttpTransport`) +instead of `endpoint`. Full reference: [WYSIWYG editor & AI](https://ficsysfr.github.io/jsonforms_builder/en/guide/wysiwyg-ai). | `string` + `options.format: pin` | `UPinInput` | | `string` + `format: color` | `UColorPicker` in a popover + text input | | `string` + `format: data-url` | `UFileUpload` | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 5876ef6..815283d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -125,6 +125,7 @@ export default defineConfig({ { text: 'Renderers', link: '/guide/renderers' }, { text: 'Personnalisation', link: '/guide/customization' }, { text: 'Intégration', link: '/guide/integration' }, + { text: 'WYSIWYG & IA', link: '/guide/wysiwyg-ai' }, { text: 'Builder visuel', link: '/guide/builder' }, { text: 'Exemples playground', link: '/guide/playground-examples' }, { text: 'Agents IA', link: '/guide/ai' }, @@ -201,6 +202,7 @@ export default defineConfig({ { text: 'Renderers', link: '/en/guide/renderers' }, { text: 'Customization', link: '/en/guide/customization' }, { text: 'Integration', link: '/en/guide/integration' }, + { text: 'WYSIWYG & AI', link: '/en/guide/wysiwyg-ai' }, { text: 'Visual builder', link: '/en/guide/builder' }, { text: 'Playground examples', link: '/en/guide/playground-examples' }, { text: 'AI agents', link: '/en/guide/ai' }, diff --git a/docs/en/guide/options/string.md b/docs/en/guide/options/string.md index 8fffb45..5b78822 100644 --- a/docs/en/guide/options/string.md +++ b/docs/en/guide/options/string.md @@ -287,10 +287,17 @@ Requires `allRenderers` (not just `nuxtUiRenderers`). |---|---|---|---| | `wysiwyg` | `Boolean` | — | **Required** (`true`) to enable the editor. | | `contentType` | `"json"` \| `"html"` | inferred | Stored format: ProseMirror object (`json`) or HTML string (`html`). | -| `toolbar` | `Array` | Nuxt UI toolbar | Replaces the toolbar (`EditorToolbarItem[]`). | +| `toolbar` | `Array` \| `false` | built-in toolbar | Replaces the toolbar (`EditorToolbarItem[][]`) or hides it. | | `placeholder` | `String` | — | Editor placeholder. | +| `features` | `false` \| `Object` | all on | Tables, task lists, alignment, colors, highlight, link, find & replace, source, preview. | +| `bubbleMenu` | `Boolean` | `false` | Formatting bubble on selection. | +| `slashCommands` | `Boolean` | `false` | `/` command palette. | +| `ai` | `Object` | — | Optional AI assistant (`enabled`, `endpoint` or `transport`, `actions`…). | | `editor` | `Object` | — | Pass-through → `UEditor`. | +Editing features, the AI assistant and an AI SDK backend are detailed in +[WYSIWYG editor & AI](/en/guide/wysiwyg-ai). + ### Examples ```json @@ -321,3 +328,4 @@ Requires `allRenderers` (not just `nuxtUiRenderers`). - [File Upload](/en/playground#/?section=docs&example=nuxt-file-upload) - [Autocomplete API](/en/playground#/?section=docs&example=nuxt-autocomplete) - [WYSIWYG](/en/playground#/?section=docs&example=nuxt-wysiwyg) +- [WYSIWYG + AI](/en/playground#/?section=docs&example=nuxt-wysiwyg-ai) diff --git a/docs/en/guide/playground-examples.md b/docs/en/guide/playground-examples.md index 435cf73..f61a4d1 100644 --- a/docs/en/guide/playground-examples.md +++ b/docs/en/guide/playground-examples.md @@ -29,6 +29,7 @@ Any example whose `name` starts with `nuxt-` automatically lands in **Documentat | [File Upload](/en/playground#/?section=docs&example=nuxt-file-upload) | `UFileUpload` | `accept`, `layout`, multi through `array` | | [Autocomplete API](/en/playground#/?section=docs&example=nuxt-autocomplete) | `UInputMenu` | `api.*`, `minLength`, `suggestion` | | [WYSIWYG](/en/playground#/?section=docs&example=nuxt-wysiwyg) | `UEditor` | `wysiwyg`, `contentType` (`json` \| `html`) | +| [WYSIWYG + AI](/en/playground#/?section=docs&example=nuxt-wysiwyg-ai) | `UEditor` + AI | `bubbleMenu`, `slashCommands`, `ai` (local transport) | ### Numbers & booleans diff --git a/docs/en/guide/wysiwyg-ai.md b/docs/en/guide/wysiwyg-ai.md new file mode 100644 index 0000000..d9eba25 --- /dev/null +++ b/docs/en/guide/wysiwyg-ai.md @@ -0,0 +1,207 @@ +# WYSIWYG editor & AI assistant + +The WYSIWYG renderer (`options.wysiwyg: true`) is still built on `UEditor` (Nuxt UI, +Tiptap 3). It adds rich editing features and an **optional** AI writing assistant designed +for an [AI SDK](https://ai-sdk.dev/) backend owned by the host application. + +`{ wysiwyg: true }` keeps working unchanged: AI, the formatting bubble and the `/` menu are +off by default. + +## Editing features + +| Option | Type | Default | Description | +|---|---|---|---| +| `features` | `false` \| `WysiwygFeatures` | all on | Toggles each capability (Tiptap extension + toolbar entries). `false` removes them all. | +| `features.taskList` | `Boolean` | `true` | Task lists. | +| `features.textAlign` | `Boolean` | `true` | Heading and paragraph alignment. | +| `features.table` | `Boolean` | `true` | Tables, insertion and the contextual bubble (rows, columns, merge, headers). | +| `features.textColor` | `Boolean` | `true` | Text color. | +| `features.highlight` | `Boolean` | `true` | Multicolor highlight. | +| `features.linkEditor` | `Boolean` | `true` | Link panel (rejects `javascript:`, `data:`…) instead of the native `prompt()`. | +| `features.findReplace` | `Boolean` | `true` | Find & replace (optional case sensitivity). | +| `features.sourceMode` | `Boolean` | `true` | Raw HTML or JSON editing; applied content goes through the Tiptap schema. | +| `features.preview` | `Boolean` | `true` | Read-only preview. | +| `bubbleMenu` | `Boolean` | `false` | Formatting bubble on text selection. | +| `slashCommands` | `Boolean` | `false` | `/` command palette. | +| `ai` | `WysiwygAiOptions` | — | AI assistant (see below). | + +A host extension passed through `options.extensions` (e.g. its own `Table`) wins over the +built-in extension of the same name. A custom `toolbar` is never altered; its items can use +the built-in `kind`s: `textColor`, `highlight`, `table`, `tableAction`, `findReplace`, +`sourceMode`, `preview`, `ai`. + +## AI assistant + +```ts +options: { + wysiwyg: true, + ai: { + enabled: true, + endpoint: '/api/ai/editor', + actions: ['improve', 'fixGrammar', 'shorter', 'longer', 'summarize', 'translate'], + }, +} +``` + +No AI code runs unless `ai.enabled === true` and an `endpoint` / `transport` is provided. + +### Flow + +1. The user selects text and picks an action (bubble, toolbar or `/ai`). +2. The request goes to the application backend, which calls AI SDK. +3. The answer is **streamed into a detached panel**: the document is never modified while + generating. +4. The user chooses: *Replace selection* / *Replace block*, *Insert*, *Insert below*, + *Retry*, *Stop* (aborts the request through `AbortController`) or *Discard*. + +Without a selection, a transform action targets the current block; a generate action +(`continue`, `paragraph`, `ask` without selection) inserts at the cursor. If the targeted +text changes while generating, the replacement is refused. + +### `ai` options + +| Option | Type | Default | Description | +|---|---|---|---| +| `enabled` | `Boolean` | — | **Required** (`true`). | +| `endpoint` | `String` | — | Backend route used by the default HTTP transport. | +| `transport` | `WysiwygAiTransport` | — | Custom transport; takes precedence over `endpoint`. | +| `actions` | `Array` | all | Default ids and/or `{ id, label, icon?, description?, instruction?, mode?, promptForInstruction? }` actions. | +| `bubbleMenu` | `Boolean` | `true` | AI menu in the selection bubble. | +| `slashCommands` | `Boolean` | `true` | AI entries in the `/` palette (filter with `/ai`). | +| `stream` | `Boolean` | `true` | Uses `transport.stream()` when available. | +| `maxContextCharacters` | `Number` | `12000` | Budget for the document context sent. | +| `includeHtml` / `includeJson` | `Boolean` | `false` | Attach the document HTML / Tiptap JSON. | +| `format` | `"text"` \| `"html"` | `"text"` | Expected format. HTML is sanitized, then filtered by the schema. | +| `headers` | `Object` | — | HTTP transport headers (never provider keys). | +| `body` | `Object` | — | Data forwarded as-is in `request.metadata`. | +| `labels` | `Object` | French | Menu and panel labels. | + +Default actions (`DEFAULT_WYSIWYG_AI_ACTIONS`): `improve`, `fixGrammar`, `shorter`, `longer`, +`simplify`, `rewrite`, `professional`, `summarize`, `translate`, `continue`, `paragraph`, +`ask`. Their instructions are provider-agnostic. + +### Request payload + +```ts +type WysiwygAiRequest = { + action: string + instruction?: string + mode: 'transform' | 'generate' + selection?: { from: number; to: number } + selectedText?: string + currentBlock?: string + textBeforeSelection?: string + textAfterSelection?: string + documentText: string // truncated to maxContextCharacters + truncated?: boolean + html?: string // only with includeHtml + json?: JSONContent // only with includeJson + format: 'text' | 'html' + metadata?: Record + stream: boolean // added by the HTTP transport +} +``` + +### AI SDK backend (Nuxt / Nitro example) + +The library depends neither on `ai` nor on any `@ai-sdk/*` package, and never calls a +provider from the browser. The model and API key stay on the server. + +```ts +// server/api/ai/editor.post.ts +import { streamText } from 'ai' +import { openai } from '@ai-sdk/openai' + +const SYSTEM = ` +You are an AI writing assistant embedded inside a rich-text editor. +Follow the requested editing action precisely. +Answer in the language of the text unless asked otherwise. +Return only the proposed text, without quotes or commentary. +`.trim() + +const buildPrompt = (r: Record) => + [ + `Action: ${r.action}`, + r.instruction && `Instruction: ${r.instruction}`, + r.selectedText ? `Text:\n${r.selectedText}` : `Text before cursor:\n${r.textBeforeSelection}`, + r.textAfterSelection && `Text after:\n${r.textAfterSelection}`, + ] + .filter(Boolean) + .join('\n\n') + +export default defineEventHandler(async (event) => { + const request = await readBody(event) + // Authenticate the user and rate-limit here. + const result = streamText({ + model: openai(process.env.AI_MODEL ?? 'gpt-5-mini'), + system: SYSTEM, + prompt: buildPrompt(request), + }) + return result.toUIMessageStreamResponse() // or result.toTextStreamResponse() +}) +``` + +The provider is swappable without touching the editor: `anthropic(...)`, `google(...)`, +`mistral(...)`, `xai(...)`, OpenRouter, any OpenAI-compatible provider, or a local model +exposed through an AI SDK provider. + +The default HTTP transport understands: + +- `toUIMessageStreamResponse()`: `text-delta`, `error` and `data-*` parts carrying + `{ operations }`; +- `toTextStreamResponse()`: streamed plain text; +- a JSON `{ text }`, `{ operation, content, explanation }` or `{ operations }` body, e.g. + from `generateText({ output: Output.object(...) })` or `generateObject()`. + +### Custom transport + +```ts +import { createWysiwygAiHttpTransport, type WysiwygAiTransport } from '@ficsysfr/jsonforms_builder' + +const transport = createWysiwygAiHttpTransport({ + endpoint: '/api/ai/editor', + headers: () => ({ 'x-csrf-token': csrfToken() }), +}) + +// Or fully custom: +const custom: WysiwygAiTransport = { + async generate(request, { signal } = {}) { + return { text: await myClient.rewrite(request, signal) } + }, + async *stream(request, { signal } = {}) { + for await (const delta of myClient.stream(request, signal)) yield delta + }, +} +``` + +Pass the transport in a UI schema built in code (`ai: { enabled: true, transport }`); a +serialized JSON UI schema uses `endpoint`. + +### Structured operations and agent tools + +A response can propose operations (`replaceSelection`, `insertBelow`, `insertAtCursor`, +`replaceText`, `insertContent`). They are validated, then **applied only after clicking +*Apply***. + +`createWysiwygEditorTools(editor)` exposes tools shaped like AI SDK `tool()` (`description`, +JSON Schema `inputSchema`, `execute`) for a future agent mode: + +- read-only, safe to run automatically: `getSelection`, `getDocumentText`, + `getCurrentBlock`, `getHeadings`, `findText`; +- proposal, never mutating: `proposeReplaceSelection`, `proposeInsertContent`, + `proposeReplaceText` return a `WysiwygAiOperation` that needs approval + (`applyWysiwygAiOperation`). + +## Security + +- No provider key in the browser: authentication, quotas and model choice belong to the + backend. +- Generated text is inserted as Tiptap text nodes, never interpreted as HTML. +- With `format: 'html'`, output goes through an allowlist (tags; `href` limited to http(s), + mailto, tel and relative) then through the Tiptap schema; the preview never uses `v-html`. +- A read-only, disabled or previewing editor neither runs nor applies AI actions. + +## Playground + +- [WYSIWYG + AI](/en/playground#/?section=docs&example=nuxt-wysiwyg-ai) (local demo + transport, no network) diff --git a/docs/guide/options/string.md b/docs/guide/options/string.md index 3a259fc..8228371 100644 --- a/docs/guide/options/string.md +++ b/docs/guide/options/string.md @@ -287,10 +287,17 @@ Nécessite `allRenderers` (pas seulement `nuxtUiRenderers`). |---|---|---|---| | `wysiwyg` | `Boolean` | — | **Requis** (`true`) pour activer l’éditeur. | | `contentType` | `"json"` \| `"html"` | inféré | Format stocké : objet ProseMirror (`json`) ou chaîne HTML (`html`). | -| `toolbar` | `Array` | toolbar Nuxt UI | Remplace la barre d’outils (`EditorToolbarItem[]`). | +| `toolbar` | `Array` \| `false` | barre intégrée | Remplace la barre d’outils (`EditorToolbarItem[][]`) ou la masque. | | `placeholder` | `String` | — | Placeholder de l’éditeur. | +| `features` | `false` \| `Object` | tout activé | Tableaux, listes de tâches, alignement, couleurs, surlignage, lien, rechercher/remplacer, source, aperçu. | +| `bubbleMenu` | `Boolean` | `false` | Bulle de mise en forme sur sélection. | +| `slashCommands` | `Boolean` | `false` | Palette de commandes `/`. | +| `ai` | `Object` | — | Assistant IA optionnel (`enabled`, `endpoint` ou `transport`, `actions`…). | | `editor` | `Object` | — | Pass-through → `UEditor`. | +Détail des fonctions d’édition, de l’assistant IA et d’un backend AI SDK : +[Éditeur WYSIWYG & IA](/guide/wysiwyg-ai). + ### Exemples ```json @@ -320,4 +327,5 @@ Nécessite `allRenderers` (pas seulement `nuxtUiRenderers`). - [Color](/playground#/?section=docs&example=nuxt-color) - [File Upload](/playground#/?section=docs&example=nuxt-file-upload) - [Autocomplete API](/playground#/?section=docs&example=nuxt-autocomplete) -- [WYSIWYG](/playground#/?section=docs&example=nuxt-wysiwyg) \ No newline at end of file +- [WYSIWYG](/playground#/?section=docs&example=nuxt-wysiwyg) +- [WYSIWYG + IA](/playground#/?section=docs&example=nuxt-wysiwyg-ai) diff --git a/docs/guide/playground-examples.md b/docs/guide/playground-examples.md index 775c01f..bab730a 100644 --- a/docs/guide/playground-examples.md +++ b/docs/guide/playground-examples.md @@ -29,6 +29,7 @@ Tout exemple dont le `name` commence par `nuxt-` atterrit automatiquement dans * | [File Upload](/playground#/?section=docs&example=nuxt-file-upload) | `UFileUpload` | `accept`, `layout`, multi via `array` | | [Autocomplete API](/playground#/?section=docs&example=nuxt-autocomplete) | `UInputMenu` | `api.*`, `minLength`, `suggestion` | | [WYSIWYG](/playground#/?section=docs&example=nuxt-wysiwyg) | `UEditor` | `wysiwyg`, `contentType` (`json` \| `html`) | +| [WYSIWYG + IA](/playground#/?section=docs&example=nuxt-wysiwyg-ai) | `UEditor` + IA | `bubbleMenu`, `slashCommands`, `ai` (transport local) | ### Nombres & booléens diff --git a/docs/guide/wysiwyg-ai.md b/docs/guide/wysiwyg-ai.md new file mode 100644 index 0000000..cba8205 --- /dev/null +++ b/docs/guide/wysiwyg-ai.md @@ -0,0 +1,209 @@ +# Éditeur WYSIWYG & assistant IA + +Le renderer WYSIWYG (`options.wysiwyg: true`) reste bâti sur `UEditor` (Nuxt UI, Tiptap 3). +Il ajoute des fonctions d'édition riches et un assistant d'écriture IA **optionnel**, conçu +pour un backend [AI SDK](https://ai-sdk.dev/) fourni par l'application hôte. + +`{ wysiwyg: true }` reste valide tel quel : l'IA, la bulle de mise en forme et le menu `/` +sont désactivés par défaut. + +## Fonctions d'édition + +| Option | Type | Défaut | Description | +|---|---|---|---| +| `features` | `false` \| `WysiwygFeatures` | tout activé | Active ou retire chaque capacité (extension Tiptap + entrées de barre). `false` les retire toutes. | +| `features.taskList` | `Boolean` | `true` | Listes de tâches. | +| `features.textAlign` | `Boolean` | `true` | Alignement des titres et paragraphes. | +| `features.table` | `Boolean` | `true` | Tableaux, insertion et bulle contextuelle (lignes, colonnes, fusion, en-têtes). | +| `features.textColor` | `Boolean` | `true` | Couleur du texte. | +| `features.highlight` | `Boolean` | `true` | Surlignage multicolore. | +| `features.linkEditor` | `Boolean` | `true` | Panneau d'édition de lien (refuse `javascript:`, `data:`…) à la place du `prompt()` natif. | +| `features.findReplace` | `Boolean` | `true` | Rechercher / remplacer (sensible à la casse en option). | +| `features.sourceMode` | `Boolean` | `true` | Édition brute du HTML ou du JSON ; le contenu appliqué passe par le schéma Tiptap. | +| `features.preview` | `Boolean` | `true` | Aperçu en lecture seule. | +| `bubbleMenu` | `Boolean` | `false` | Bulle de mise en forme sur sélection de texte. | +| `slashCommands` | `Boolean` | `false` | Palette de commandes `/`. | +| `ai` | `WysiwygAiOptions` | — | Assistant IA (voir ci-dessous). | + +Une extension fournie par l'hôte via `options.extensions` (par exemple sa propre `Table`) +prend le pas sur l'extension intégrée du même nom. Une `toolbar` personnalisée n'est jamais +modifiée ; ses éléments peuvent utiliser les `kind` intégrés : `textColor`, `highlight`, +`table`, `tableAction`, `findReplace`, `sourceMode`, `preview`, `ai`. + +## Assistant IA + +```ts +options: { + wysiwyg: true, + ai: { + enabled: true, + endpoint: '/api/ai/editor', + actions: ['improve', 'fixGrammar', 'shorter', 'longer', 'summarize', 'translate'], + }, +} +``` + +Aucun code IA ne s'exécute tant que `ai.enabled !== true` ou qu'aucun `endpoint` / +`transport` n'est fourni. + +### Parcours + +1. L'utilisateur sélectionne du texte puis choisit une action (bulle, barre d'outils ou `/ai`). +2. La requête part vers le backend de l'application, qui appelle AI SDK. +3. La réponse est **streamée dans un panneau détaché** : le document n'est jamais modifié + pendant la génération. +4. L'utilisateur choisit : *Remplacer la sélection* / *Remplacer le bloc*, *Insérer*, + *Insérer en dessous*, *Réessayer*, *Arrêter* (annule la requête via `AbortController`) + ou *Ignorer*. + +Sans sélection, une action de transformation porte sur le bloc courant ; une action de +génération (`continue`, `paragraph`, `ask` sans sélection) insère au curseur. Si le texte +ciblé est modifié pendant la génération, le remplacement est refusé. + +### Options `ai` + +| Option | Type | Défaut | Description | +|---|---|---|---| +| `enabled` | `Boolean` | — | **Requis** (`true`). | +| `endpoint` | `String` | — | Route du backend, utilisée par le transport HTTP par défaut. | +| `transport` | `WysiwygAiTransport` | — | Transport personnalisé ; prioritaire sur `endpoint`. | +| `actions` | `Array` | toutes | Identifiants par défaut et/ou actions `{ id, label, icon?, description?, instruction?, mode?, promptForInstruction? }`. | +| `bubbleMenu` | `Boolean` | `true` | Menu IA dans la bulle de sélection. | +| `slashCommands` | `Boolean` | `true` | Entrées IA dans la palette `/` (filtrables avec `/ai`). | +| `stream` | `Boolean` | `true` | Utilise `transport.stream()` quand il existe. | +| `maxContextCharacters` | `Number` | `12000` | Budget du contexte documentaire envoyé. | +| `includeHtml` / `includeJson` | `Boolean` | `false` | Joint le HTML / JSON Tiptap du document. | +| `format` | `"text"` \| `"html"` | `"text"` | Format attendu. Le HTML est assaini puis filtré par le schéma. | +| `headers` | `Object` | — | En-têtes du transport HTTP (jamais de clé de fournisseur). | +| `body` | `Object` | — | Données transmises telles quelles dans `request.metadata`. | +| `labels` | `Object` | français | Libellés du menu et du panneau. | + +Actions par défaut (`DEFAULT_WYSIWYG_AI_ACTIONS`) : `improve`, `fixGrammar`, `shorter`, +`longer`, `simplify`, `rewrite`, `professional`, `summarize`, `translate`, `continue`, +`paragraph`, `ask`. Leurs instructions sont neutres vis-à-vis du fournisseur. + +### Requête envoyée + +```ts +type WysiwygAiRequest = { + action: string + instruction?: string + mode: 'transform' | 'generate' + selection?: { from: number; to: number } + selectedText?: string + currentBlock?: string + textBeforeSelection?: string + textAfterSelection?: string + documentText: string // tronqué à maxContextCharacters + truncated?: boolean + html?: string // seulement avec includeHtml + json?: JSONContent // seulement avec includeJson + format: 'text' | 'html' + metadata?: Record + stream: boolean // ajouté par le transport HTTP +} +``` + +### Backend AI SDK (exemple Nuxt / Nitro) + +La bibliothèque ne dépend ni de `ai` ni d'un paquet `@ai-sdk/*`, et n'appelle jamais un +fournisseur depuis le navigateur. Le modèle et la clé d'API restent côté serveur. + +```ts +// server/api/ai/editor.post.ts +import { streamText } from 'ai' +import { openai } from '@ai-sdk/openai' + +const SYSTEM = ` +You are an AI writing assistant embedded inside a rich-text editor. +Follow the requested editing action precisely. +Answer in the language of the text unless asked otherwise. +Return only the proposed text, without quotes or commentary. +`.trim() + +const buildPrompt = (r: Record) => + [ + `Action: ${r.action}`, + r.instruction && `Instruction: ${r.instruction}`, + r.selectedText ? `Text:\n${r.selectedText}` : `Text before cursor:\n${r.textBeforeSelection}`, + r.textAfterSelection && `Text after:\n${r.textAfterSelection}`, + ] + .filter(Boolean) + .join('\n\n') + +export default defineEventHandler(async (event) => { + const request = await readBody(event) + // Authentifier l'utilisateur et limiter le débit ici. + const result = streamText({ + model: openai(process.env.AI_MODEL ?? 'gpt-5-mini'), + system: SYSTEM, + prompt: buildPrompt(request), + }) + return result.toUIMessageStreamResponse() // ou result.toTextStreamResponse() +}) +``` + +Le fournisseur se remplace sans toucher à l'éditeur : `anthropic(...)`, `google(...)`, +`mistral(...)`, `xai(...)`, OpenRouter, un fournisseur compatible OpenAI ou un modèle local +exposé par un fournisseur AI SDK. + +Le transport HTTP par défaut comprend : + +- `toUIMessageStreamResponse()` : parts `text-delta`, `error` et `data-*` portant + `{ operations }` ; +- `toTextStreamResponse()` : texte brut streamé ; +- une réponse JSON `{ text }`, `{ operation, content, explanation }` ou `{ operations }`, + par exemple issue de `generateText({ output: Output.object(...) })` ou `generateObject()`. + +### Transport personnalisé + +```ts +import { createWysiwygAiHttpTransport, type WysiwygAiTransport } from '@ficsysfr/jsonforms_builder' + +const transport = createWysiwygAiHttpTransport({ + endpoint: '/api/ai/editor', + headers: () => ({ 'x-csrf-token': csrfToken() }), +}) + +// Ou entièrement sur mesure : +const custom: WysiwygAiTransport = { + async generate(request, { signal } = {}) { + return { text: await myClient.rewrite(request, signal) } + }, + async *stream(request, { signal } = {}) { + for await (const delta of myClient.stream(request, signal)) yield delta + }, +} +``` + +Le transport se passe dans le UI schema côté code (`ai: { enabled: true, transport }`) ; un +UI schema JSON sérialisé utilise `endpoint`. + +### Opérations structurées et outils d'agent + +Une réponse peut proposer des opérations (`replaceSelection`, `insertBelow`, +`insertAtCursor`, `replaceText`, `insertContent`). Elles sont validées puis **appliquées +seulement après le clic sur *Appliquer***. + +`createWysiwygEditorTools(editor)` expose des outils au format `tool()` d'AI SDK +(`description`, `inputSchema` JSON Schema, `execute`) pour un futur mode agent : + +- lecture, exécutables automatiquement : `getSelection`, `getDocumentText`, + `getCurrentBlock`, `getHeadings`, `findText` ; +- proposition, sans mutation : `proposeReplaceSelection`, `proposeInsertContent`, + `proposeReplaceText` renvoient une `WysiwygAiOperation` soumise à approbation + (`applyWysiwygAiOperation`). + +## Sécurité + +- Aucune clé de fournisseur dans le navigateur : authentification, quotas et choix du modèle + relèvent du backend. +- Le texte généré est inséré comme nœuds texte Tiptap, jamais interprété comme HTML. +- Avec `format: 'html'`, la sortie passe par une liste blanche (balises, `href` limité à + http(s), mailto, tel et relatif) puis par le schéma Tiptap ; l'aperçu n'utilise pas `v-html`. +- L'éditeur en lecture seule, désactivé ou en aperçu n'exécute ni n'applique aucune action IA. + +## Playground + +- [WYSIWYG + IA](/playground#/?section=docs&example=nuxt-wysiwyg-ai) (transport de + démonstration local, sans réseau) diff --git a/package.json b/package.json index 0c5e992..4260d09 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,11 @@ }, "dependencies": { "@internationalized/date": "^3.12.1", + "@tiptap/extension-highlight": "^3.29.2", + "@tiptap/extension-list": "^3.29.2", + "@tiptap/extension-table": "^3.29.2", + "@tiptap/extension-text-align": "^3.29.2", + "@tiptap/extension-text-style": "^3.29.2", "defu": "^6.1.4", "radash": "^12.1.1" }, diff --git a/playground/docs-api/props.ts b/playground/docs-api/props.ts index 9274f16..208db32 100644 --- a/playground/docs-api/props.ts +++ b/playground/docs-api/props.ts @@ -596,8 +596,30 @@ const WYSIWYG: ApiPropGroup = { }, { name: 'toolbar', - type: 'Array', - description: 'Remplace la barre d’outils.', + type: 'Array | false', + description: 'Remplace la barre d’outils ou la masque.', + }, + { + name: 'features', + type: 'false | Object', + description: + 'Tableaux, tâches, alignement, couleurs, surlignage, lien, rechercher/remplacer, source, aperçu (tout activé).', + }, + { + name: 'bubbleMenu', + type: 'Boolean', + description: 'Bulle de mise en forme sur sélection (défaut false).', + }, + { + name: 'slashCommands', + type: 'Boolean', + description: 'Palette de commandes / (défaut false).', + }, + { + name: 'ai', + type: 'Object', + description: + 'Assistant IA optionnel : enabled, endpoint ou transport, actions, maxContextCharacters…', }, { name: 'editor', @@ -708,6 +730,7 @@ export const API_BY_EXAMPLE: Record = { 'nuxt-autocomplete': [COMMON_OPTIONS, API_AUTOCOMPLETE], 'nuxt-array': [COMMON_OPTIONS, ARRAY], 'nuxt-wysiwyg': [COMMON_OPTIONS, WYSIWYG], + 'nuxt-wysiwyg-ai': [COMMON_OPTIONS, WYSIWYG], 'nuxt-layouts': [COMMON_OPTIONS, LAYOUTS], 'nuxt-pin-input': [COMMON_OPTIONS, PIN], 'nuxt-color': [COMMON_OPTIONS, COLOR], diff --git a/playground/examples/items/nuxt-wysiwyg-ai.ts b/playground/examples/items/nuxt-wysiwyg-ai.ts new file mode 100644 index 0000000..b53fc26 --- /dev/null +++ b/playground/examples/items/nuxt-wysiwyg-ai.ts @@ -0,0 +1,99 @@ +import type { + WysiwygAiRequest, + WysiwygAiStreamChunk, + WysiwygAiTransport, +} from '../../../src/advanced/wysiwyg/ai/types' +import { registerExamples } from '../register' + +const wait = (ms: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms) + signal?.addEventListener('abort', () => { + clearTimeout(timer) + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })) + }) + }) + +/** Local stand-in for an AI SDK backend: deterministic, no network. */ +const fakeAnswer = (request: WysiwygAiRequest): string => { + const text = request.selectedText ?? '' + switch (request.action) { + case 'shorter': + case 'summarize': + return text + .split(/\s+/) + .slice(0, Math.max(3, Math.ceil(text.split(/\s+/).length / 2))) + .join(' ') + case 'longer': + return `${text} Cette version développée précise le contexte et les prochaines étapes.` + case 'professional': + return `Nous vous informons que ${text.charAt(0).toLowerCase()}${text.slice(1)}` + case 'continue': + return ' La suite de la rédaction reprend le fil du paragraphe précédent.' + case 'paragraph': + return `Paragraphe généré à propos de « ${request.instruction?.split('\n\n').at(-1) ?? 'ce sujet'} ».` + default: + return text ? text.charAt(0).toUpperCase() + text.slice(1).replace(/\s+/g, ' ').trim() : '…' + } +} + +const demoTransport: WysiwygAiTransport = { + async generate(request, options) { + await wait(400, options?.signal) + return { text: fakeAnswer(request) } + }, + async *stream(request, options): AsyncGenerator { + for (const word of fakeAnswer(request).split(/(?<=\s)/)) { + await wait(60, options?.signal) + yield word + } + }, +} + +export const data = { + notes: + '

Compte-rendu

la réunion a permis de faire le point sur le chantier et les livraisons prévues la semaine prochaine.

  • Valider le planning

', +} + +export const schema = { + type: 'object', + properties: { + notes: { + type: 'string', + title: 'Notes (HTML) avec assistant IA', + description: + 'Sélectionnez du texte puis « IA », ou tapez / . Transport de démonstration local (aucun appel réseau).', + }, + }, +} + +export const uischema = { + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/notes', + options: { + wysiwyg: true, + contentType: 'html', + bubbleMenu: true, + slashCommands: true, + showUnfocusedDescription: true, + ai: { + enabled: true, + transport: demoTransport, + }, + }, + }, + ], +} + +registerExamples([ + { + name: 'nuxt-wysiwyg-ai', + label: 'Nuxt UI — WYSIWYG + IA', + data, + schema, + uischema, + }, +]) diff --git a/src/advanced/index.ts b/src/advanced/index.ts index 1b36c99..158666f 100644 --- a/src/advanced/index.ts +++ b/src/advanced/index.ts @@ -15,6 +15,35 @@ export { type WysiwygHandlers, type ResolvedWysiwygOptions, } from './wysiwygOptions' +export * from './wysiwyg/ai' +export { + DEFAULT_WYSIWYG_FEATURES, + resolveWysiwygFeatures, + type ResolvedWysiwygFeatures, + type WysiwygFeatures, +} from './wysiwyg/features' +export { buildWysiwygFeatureExtensions } from './wysiwyg/extensions' +export { WYSIWYG_HIGHLIGHT_COLORS, WYSIWYG_TEXT_COLORS, type WysiwygColor } from './wysiwyg/colors' +export { + WysiwygSearch, + findWysiwygTextRanges, + getWysiwygSearchState, + type WysiwygSearchState, + type WysiwygTextRange, +} from './wysiwyg/search' +export { + WYSIWYG_TABLE_BUBBLE, + buildWysiwygTextBubble, + buildWysiwygToolbar, +} from './wysiwyg/toolbar' +export { WYSIWYG_SLASH_FILTER_FIELDS, buildWysiwygSlashItems } from './wysiwyg/slash/commands' +export { + WYSIWYG_TABLE_ACTIONS, + createWysiwygHandlers, + type WysiwygEditorHandler, + type WysiwygTableAction, + type WysiwygUiState, +} from './wysiwyg/handlers' // `rendererEntry` pairs the entry with the component's default export — see its doc: // without that reference the compiled template is tree-shaken out of production builds. diff --git a/src/advanced/wysiwyg.vue b/src/advanced/wysiwyg.vue index a978bc0..5fbd24d 100644 --- a/src/advanced/wysiwyg.vue +++ b/src/advanced/wysiwyg.vue @@ -6,32 +6,93 @@ :show-description="showDescription()" :hide-required-asterisk="!!appliedOptions.hideRequiredAsterisk" ) - u-editor( - v-bind="uiProps('editor')" - :model-value="control.data" - :content-type="resolved.contentType" - :editable="!isDisabled && !isReadonly" - :placeholder="resolved.placeholder" - :image="false" - :extensions="editorExtensions" - :handlers="editorHandlers" - :class="[styles.control.input, 'rounded-md border border-default']" - :ui="editorUi" - @update:model-value="onChange" - ) - template(#default="{ editor }") - u-editor-toolbar.sticky.top-0.z-10.rounded-t-md.border-b.border-default.bg-default.p-1( - v-if="resolved.toolbar !== false" - :editor="editor" - :items="resolved.toolbar" - ) - u-editor-toolbar( - v-if="resolved.imageBubble && !isDisabled && !isReadonly" - :editor="editor" - :items="imageBubbleItems(editor)" - layout="bubble" - :should-show="shouldShowImageBubble" - ) + .relative(ref="rootEl") + u-editor( + ref="editorRef" + v-bind="uiProps('editor')" + :model-value="control.data" + :content-type="resolved.contentType" + :editable="editable" + :placeholder="resolved.placeholder" + :image="false" + :extensions="editorExtensions" + :handlers="editorHandlers" + :class="[styles.control.input, 'rounded-md border border-default']" + :ui="editorUi" + @update:model-value="onChange" + ) + template(#default="{ editor }") + .sticky.top-0.z-10.flex.items-center.gap-2.rounded-t-md.border-b.border-default.bg-default.p-1( + v-if="ui.mode === 'preview'" + data-wysiwyg-preview-bar + ) + span.flex.items-center.gap-1.px-1.text-xs.text-muted + u-icon.size-4(name="i-lucide-eye") + | Aperçu + u-button.ms-auto(size="xs" color="neutral" variant="ghost" icon="i-lucide-pencil" @click="ui.mode = 'edit'") Modifier + template(v-else) + u-editor-toolbar.sticky.top-0.z-10.flex-wrap.rounded-t-md.border-b.border-default.bg-default.p-1( + v-if="resolved.toolbar !== false && ui.mode === 'edit'" + :editor="editor" + :items="resolved.toolbar" + ) + wysiwyg-source-editor( + v-if="ui.mode === 'source'" + :editor="editor" + :content-type="resolved.contentType" + :editable="editable" + @close="ui.mode = 'edit'" + ) + wysiwyg-find-replace( + v-else-if="ui.panel === 'find'" + :editor="editor" + :editable="editable" + @close="closePanel(editor)" + ) + wysiwyg-link-panel( + v-else-if="ui.panel === 'link' && editable" + :editor="editor" + @close="closePanel(editor)" + ) + template(v-if="editable && ui.mode === 'edit'") + u-editor-toolbar( + v-if="resolved.imageBubble" + :editor="editor" + :items="imageBubbleItems(editor)" + layout="bubble" + plugin-key="wysiwygImageBubble" + :should-show="shouldShowImageBubble" + ) + u-editor-toolbar( + v-if="textBubbleItems.length" + :editor="editor" + :items="textBubbleItems" + layout="bubble" + plugin-key="wysiwygTextBubble" + :should-show="shouldShowTextBubble" + ) + u-editor-toolbar( + v-if="resolved.features.table" + :editor="editor" + :items="tableBubbleItems" + layout="bubble" + plugin-key="wysiwygTableBubble" + :should-show="shouldShowTableBubble" + ) + u-editor-suggestion-menu( + v-if="slashItems.length" + :editor="editor" + :items="slashItems" + :filter-fields="slashFilterFields" + plugin-key="wysiwygSlashMenu" + ) + wysiwyg-ai-result.absolute.inset-x-2.z-20( + v-if="ai && ai.isOpen.value" + :ai="ai" + :labels="resolved.ai.labels" + :format="resolved.ai.format" + :style="aiPanelStyle" + ) diff --git a/src/advanced/wysiwyg/WysiwygLinkPanel.vue b/src/advanced/wysiwyg/WysiwygLinkPanel.vue new file mode 100644 index 0000000..0af3fbf --- /dev/null +++ b/src/advanced/wysiwyg/WysiwygLinkPanel.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/advanced/wysiwyg/WysiwygSourceEditor.vue b/src/advanced/wysiwyg/WysiwygSourceEditor.vue new file mode 100644 index 0000000..b6ce81b --- /dev/null +++ b/src/advanced/wysiwyg/WysiwygSourceEditor.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/advanced/wysiwyg/ai/WysiwygAiResult.vue b/src/advanced/wysiwyg/ai/WysiwygAiResult.vue new file mode 100644 index 0000000..b36577b --- /dev/null +++ b/src/advanced/wysiwyg/ai/WysiwygAiResult.vue @@ -0,0 +1,146 @@ + + + diff --git a/src/advanced/wysiwyg/ai/actions.ts b/src/advanced/wysiwyg/ai/actions.ts new file mode 100644 index 0000000..281187d --- /dev/null +++ b/src/advanced/wysiwyg/ai/actions.ts @@ -0,0 +1,168 @@ +import type { WysiwygAiAction, WysiwygAiActionInput, WysiwygAiLabels } from './types' + +/** + * Built-in actions. Instructions are provider-agnostic: the backend decides the model, + * the system prompt and how to use them. + */ +export const DEFAULT_WYSIWYG_AI_ACTIONS: readonly WysiwygAiAction[] = Object.freeze([ + { + id: 'improve', + label: 'Améliorer la rédaction', + icon: 'i-lucide-wand-sparkles', + instruction: 'Improve the selected text while preserving its meaning.', + }, + { + id: 'fixGrammar', + label: 'Corriger orthographe et grammaire', + icon: 'i-lucide-spell-check', + instruction: 'Correct spelling, grammar and punctuation without changing meaning.', + }, + { + id: 'shorter', + label: 'Raccourcir', + icon: 'i-lucide-minimize-2', + instruction: 'Rewrite the text more concisely.', + }, + { + id: 'longer', + label: 'Développer', + icon: 'i-lucide-maximize-2', + instruction: 'Expand the text while preserving its original intent.', + }, + { + id: 'simplify', + label: 'Simplifier', + icon: 'i-lucide-baseline', + instruction: 'Rewrite using simpler and clearer language.', + }, + { + id: 'rewrite', + label: 'Reformuler', + icon: 'i-lucide-repeat', + instruction: 'Rephrase the text differently while keeping the same meaning.', + }, + { + id: 'professional', + label: 'Ton professionnel', + icon: 'i-lucide-briefcase', + instruction: 'Rewrite using a professional tone.', + }, + { + id: 'summarize', + label: 'Résumer', + icon: 'i-lucide-list-collapse', + instruction: 'Summarize the text, keeping only the key points.', + }, + { + id: 'translate', + label: 'Traduire', + icon: 'i-lucide-languages', + instruction: + 'Translate the text into the language given in the user instruction, or into English when none is given.', + promptForInstruction: true, + }, + { + id: 'continue', + label: 'Continuer la rédaction', + icon: 'i-lucide-pen-line', + mode: 'generate', + instruction: 'Continue writing from the cursor, matching the style and language of the text.', + }, + { + id: 'paragraph', + label: 'Générer un paragraphe', + icon: 'i-lucide-pilcrow', + mode: 'generate', + instruction: 'Write one paragraph about the subject given in the user instruction.', + promptForInstruction: true, + }, + { + id: 'ask', + label: 'Demander à l’IA', + icon: 'i-lucide-sparkles', + mode: 'auto', + promptForInstruction: true, + }, +]) + +/** Actions shown when `ai.actions` is omitted. */ +export const DEFAULT_WYSIWYG_AI_ACTION_IDS = [ + 'improve', + 'fixGrammar', + 'shorter', + 'longer', + 'simplify', + 'rewrite', + 'professional', + 'summarize', + 'translate', + 'continue', + 'paragraph', + 'ask', +] as const + +/** Subset exposed in the `/` palette. */ +export const DEFAULT_WYSIWYG_AI_SLASH_ACTION_IDS = [ + 'ask', + 'continue', + 'paragraph', + 'improve', + 'summarize', + 'translate', +] as const + +export const DEFAULT_WYSIWYG_AI_LABELS: Readonly = Object.freeze({ + menu: 'IA', + ask: 'Demander à l’IA', + instructionPlaceholder: 'Décrivez ce que l’IA doit faire…', + submit: 'Envoyer', + generating: 'Génération…', + done: 'Proposition prête', + error: 'La génération a échoué', + replaceSelection: 'Remplacer la sélection', + replaceBlock: 'Remplacer le bloc', + insert: 'Insérer', + insertBelow: 'Insérer en dessous', + apply: 'Appliquer', + retry: 'Réessayer', + stop: 'Arrêter', + discard: 'Ignorer', + empty: 'Aucun contenu proposé.', +}) + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +/** + * Resolves `ai.actions`: string ids map to defaults (unknown ids are dropped); objects + * override a default with the same id or add a custom action. Later duplicates win. + */ +export const resolveWysiwygAiActions = (input: unknown): WysiwygAiAction[] => { + const defaults = new Map(DEFAULT_WYSIWYG_AI_ACTIONS.map((a) => [a.id, a])) + const list: unknown[] = Array.isArray(input) ? input : [...DEFAULT_WYSIWYG_AI_ACTION_IDS] + const resolved = new Map() + + for (const item of list as WysiwygAiActionInput[]) { + if (typeof item === 'string') { + const found = defaults.get(item) + if (found) resolved.set(found.id, { ...found }) + continue + } + if (!isRecord(item) || typeof item.id !== 'string' || !item.id) continue + const base = defaults.get(item.id) + resolved.set(item.id, { + ...base, + ...item, + label: typeof item.label === 'string' && item.label ? item.label : (base?.label ?? item.id), + }) + } + + return [...resolved.values()] +} + +/** Actions to list in the `/` palette: defaults subset, plus every custom action. */ +export const selectWysiwygAiSlashActions = (actions: WysiwygAiAction[]): WysiwygAiAction[] => { + const slashIds = new Set(DEFAULT_WYSIWYG_AI_SLASH_ACTION_IDS) + const defaultIds = new Set(DEFAULT_WYSIWYG_AI_ACTIONS.map((a) => a.id)) + return actions.filter((a) => slashIds.has(a.id) || !defaultIds.has(a.id)) +} diff --git a/src/advanced/wysiwyg/ai/context.ts b/src/advanced/wysiwyg/ai/context.ts new file mode 100644 index 0000000..8b72731 --- /dev/null +++ b/src/advanced/wysiwyg/ai/context.ts @@ -0,0 +1,145 @@ +import type { Editor } from '@tiptap/core' +import type { + WysiwygAiAction, + WysiwygAiActionMode, + WysiwygAiOutputFormat, + WysiwygAiRequest, +} from './types' + +export const DEFAULT_AI_MAX_CONTEXT_CHARACTERS = 12000 + +const BLOCK_SEPARATOR = '\n\n' +const LEAF_TEXT = ' ' + +/** Where an AI result lands once the user accepts it. */ +export type WysiwygAiTarget = { + mode: WysiwygAiActionMode + /** `selection`: non-empty selection; `block`: current block; `cursor`: insertion point. */ + kind: 'selection' | 'block' | 'cursor' + from: number + to: number + /** Text the action operates on (empty for `cursor`). */ + text: string + /** Position right after the top-level block containing `to`. */ + blockEnd: number +} + +export type BuildWysiwygAiRequestOptions = { + instruction?: string + maxContextCharacters?: number + includeHtml?: boolean + includeJson?: boolean + format?: WysiwygAiOutputFormat + metadata?: Record +} + +const head = (text: string, max: number) => (text.length > max ? text.slice(0, max) : text) +const tail = (text: string, max: number) => + text.length > max ? text.slice(text.length - max) : text + +const topLevelBlockEnd = (editor: Editor, pos: number): number => { + const { doc } = editor.state + const $pos = doc.resolve(Math.min(pos, doc.content.size)) + return $pos.depth >= 1 ? $pos.after(1) : doc.content.size +} + +/** + * Resolves what an action operates on from the current selection: + * the selection itself, else the current (non-empty) text block for `transform` + * actions, else the cursor for `generate` actions. + */ +export const resolveWysiwygAiTarget = ( + editor: Editor, + action: Pick, +): WysiwygAiTarget => { + const { doc, selection } = editor.state + const requested = action.mode ?? 'transform' + + if (!selection.empty && requested !== 'generate') { + const { from, to } = selection + return { + mode: 'transform', + kind: 'selection', + from, + to, + text: doc.textBetween(from, to, BLOCK_SEPARATOR, LEAF_TEXT), + blockEnd: topLevelBlockEnd(editor, to), + } + } + + const { $from } = selection + const parent = $from.parent + if (requested === 'transform' && parent.isTextblock && parent.textContent.trim()) { + const from = $from.start() + const to = $from.end() + return { + mode: 'transform', + kind: 'block', + from, + to, + text: parent.textContent, + blockEnd: topLevelBlockEnd(editor, to), + } + } + + const pos = selection.to + return { + mode: 'generate', + kind: 'cursor', + from: pos, + to: pos, + text: '', + blockEnd: topLevelBlockEnd(editor, pos), + } +} + +/** + * Builds the backend payload. Document context is bounded by `maxContextCharacters`; + * HTML / JSON are only attached when explicitly requested. + */ +export const buildWysiwygAiRequest = ( + editor: Editor, + action: WysiwygAiAction, + target: WysiwygAiTarget, + options: BuildWysiwygAiRequestOptions = {}, +): WysiwygAiRequest => { + const { doc, selection } = editor.state + const max = + typeof options.maxContextCharacters === 'number' && options.maxContextCharacters >= 0 + ? Math.floor(options.maxContextCharacters) + : DEFAULT_AI_MAX_CONTEXT_CHARACTERS + const half = Math.floor(max / 2) + const size = doc.content.size + + const fullText = doc.textBetween(0, size, BLOCK_SEPARATOR, LEAF_TEXT) + const before = doc.textBetween(0, target.from, BLOCK_SEPARATOR, LEAF_TEXT) + const after = doc.textBetween(target.to, size, BLOCK_SEPARATOR, LEAF_TEXT) + const block = selection.$from.parent.textContent + + const truncated = + fullText.length > max || before.length > half || after.length > half || block.length > max + + const instruction = [action.instruction, options.instruction?.trim()] + .filter((part): part is string => !!part) + .join('\n\n') + + const request: WysiwygAiRequest = { + action: action.id, + mode: target.mode, + selection: { from: target.from, to: target.to }, + documentText: head(fullText, max), + currentBlock: head(block, max), + textBeforeSelection: tail(before, half), + textAfterSelection: head(after, half), + format: options.format ?? 'text', + } + if (instruction) request.instruction = instruction + if (target.text) request.selectedText = target.text + if (truncated) request.truncated = true + if (options.includeHtml) request.html = editor.getHTML() + if (options.includeJson) request.json = editor.getJSON() + if (options.metadata && Object.keys(options.metadata).length) { + request.metadata = { ...options.metadata } + } + return request +} diff --git a/src/advanced/wysiwyg/ai/httpTransport.ts b/src/advanced/wysiwyg/ai/httpTransport.ts new file mode 100644 index 0000000..6c0b9d4 --- /dev/null +++ b/src/advanced/wysiwyg/ai/httpTransport.ts @@ -0,0 +1,144 @@ +import { + UI_MESSAGE_STREAM_HEADER, + collectWysiwygAiStream, + parseTextStream, + parseUiMessageStream, + parseWysiwygAiJsonResponse, +} from './stream' +import type { + WysiwygAiRequest, + WysiwygAiStreamChunk, + WysiwygAiTransport, + WysiwygAiTransportCallOptions, +} from './types' + +export type WysiwygAiHttpTransportOptions = { + /** Backend route implemented with AI SDK, e.g. `/api/ai/editor`. */ + endpoint: string + /** Static headers, or a function resolved per request (e.g. CSRF token). Never provider keys. */ + headers?: + | Record + | (() => Record | Promise>) + /** Default: `same-origin`. */ + credentials?: RequestCredentials + /** Custom `fetch` (tests, SSR, interceptors). Default: `globalThis.fetch`. */ + fetch?: typeof fetch +} + +export class WysiwygAiTransportError extends Error { + readonly status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'WysiwygAiTransportError' + this.status = status + } +} + +const MAX_ERROR_LENGTH = 300 + +type StreamProtocol = 'ui-message' | 'json' | 'text' + +const detectProtocol = (response: Response): StreamProtocol => { + const contentType = response.headers.get('content-type') ?? '' + if (response.headers.has(UI_MESSAGE_STREAM_HEADER) || contentType.includes('text/event-stream')) { + return 'ui-message' + } + if (contentType.includes('application/json')) return 'json' + return 'text' +} + +const errorMessage = async (response: Response): Promise => { + let detail = '' + try { + const text = (await response.text()).trim() + try { + const json = JSON.parse(text) as Record + const message = json.message ?? json.statusMessage ?? json.error + detail = typeof message === 'string' ? message : text + } catch { + detail = text + } + } catch { + // Body unreadable: fall back to the status line. + } + const base = `AI request failed (${response.status}${response.statusText ? ` ${response.statusText}` : ''})` + return detail ? `${base}: ${detail.slice(0, MAX_ERROR_LENGTH)}` : base +} + +/** + * Default transport: POSTs the editor request as JSON and understands the responses an + * AI SDK backend produces: + * - `result.toUIMessageStreamResponse()` (SSE, `text-delta` / `error` / `data-*` parts); + * - `result.toTextStreamResponse()` (plain text stream); + * - JSON `{ text }` / `{ operation, content }` / `{ operations }` (e.g. `generateText` + * with `Output.object()`, or `generateObject()`). + * + * The body carries `stream: true | false` so one route can serve both calls. + */ +export const createWysiwygAiHttpTransport = ( + options: WysiwygAiHttpTransportOptions, +): WysiwygAiTransport => { + const post = async ( + request: WysiwygAiRequest, + stream: boolean, + call: WysiwygAiTransportCallOptions = {}, + ): Promise => { + const fetchImpl = options.fetch ?? globalThis.fetch + if (typeof fetchImpl !== 'function') { + throw new WysiwygAiTransportError('fetch is not available', 0) + } + const extra = typeof options.headers === 'function' ? await options.headers() : options.headers + const response = await fetchImpl(options.endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: stream ? 'text/event-stream, text/plain, application/json' : 'application/json', + ...extra, + }, + credentials: options.credentials ?? 'same-origin', + body: JSON.stringify({ ...request, stream }), + signal: call.signal, + }) + if (!response.ok) { + throw new WysiwygAiTransportError(await errorMessage(response), response.status) + } + return response + } + + const chunksOf = (response: Response): AsyncIterable => { + const protocol = detectProtocol(response) + if (protocol === 'json' || !response.body) { + return (async function* () { + const body = + protocol === 'json' ? ((await response.json()) as unknown) : await response.text() + const parsed = parseWysiwygAiJsonResponse(body) + if (parsed.text) yield { type: 'text-delta', delta: parsed.text } + if (parsed.operations) { + yield { + type: 'operations', + operations: parsed.operations, + ...(parsed.explanation ? { explanation: parsed.explanation } : {}), + } + } + })() + } + return protocol === 'ui-message' + ? parseUiMessageStream(response.body) + : parseTextStream(response.body) + } + + return { + async generate(request, call) { + const response = await post(request, false, call) + if (detectProtocol(response) === 'json') { + return parseWysiwygAiJsonResponse(await response.json()) + } + return collectWysiwygAiStream(chunksOf(response)) + }, + async *stream(request, call) { + const response = await post(request, true, call) + yield* chunksOf(response) + }, + } +} diff --git a/src/advanced/wysiwyg/ai/index.ts b/src/advanced/wysiwyg/ai/index.ts new file mode 100644 index 0000000..7c8cf7d --- /dev/null +++ b/src/advanced/wysiwyg/ai/index.ts @@ -0,0 +1,51 @@ +export { + DEFAULT_WYSIWYG_AI_ACTIONS, + DEFAULT_WYSIWYG_AI_ACTION_IDS, + DEFAULT_WYSIWYG_AI_SLASH_ACTION_IDS, + DEFAULT_WYSIWYG_AI_LABELS, + resolveWysiwygAiActions, + selectWysiwygAiSlashActions, +} from './actions' +export { + DEFAULT_AI_MAX_CONTEXT_CHARACTERS, + buildWysiwygAiRequest, + resolveWysiwygAiTarget, + type BuildWysiwygAiRequestOptions, + type WysiwygAiTarget, +} from './context' +export { + createWysiwygAiHttpTransport, + WysiwygAiTransportError, + type WysiwygAiHttpTransportOptions, +} from './httpTransport' +export { + applyWysiwygAiOperation, + normalizeWysiwygAiOperations, + textToWysiwygParagraphs, +} from './operations' +export { + DISABLED_WYSIWYG_AI, + resolveWysiwygAiOptions, + type ResolvedWysiwygAiOptions, +} from './options' +export { isSafeWysiwygUrl, sanitizeWysiwygAiHtml } from './sanitize' +export { + UI_MESSAGE_STREAM_HEADER, + collectWysiwygAiStream, + parseTextStream, + parseUiMessageStream, + parseWysiwygAiJsonResponse, +} from './stream' +export { + createWysiwygEditorTools, + type WysiwygEditorTool, + type WysiwygEditorToolKind, +} from './tools' +export type * from './types' +export { + useWysiwygAi, + type UseWysiwygAiOptions, + type WysiwygAiAcceptMode, + type WysiwygAiController, + type WysiwygAiStatus, +} from './useWysiwygAi' diff --git a/src/advanced/wysiwyg/ai/operations.ts b/src/advanced/wysiwyg/ai/operations.ts new file mode 100644 index 0000000..3523ee0 --- /dev/null +++ b/src/advanced/wysiwyg/ai/operations.ts @@ -0,0 +1,148 @@ +import type { Editor, JSONContent } from '@tiptap/core' +import type { WysiwygAiTarget } from './context' +import { sanitizeWysiwygAiHtml } from './sanitize' +import type { WysiwygAiOperation, WysiwygAiOutputFormat } from './types' + +const OPERATION_TYPES = new Set([ + 'replaceSelection', + 'insertBelow', + 'insertAtCursor', + 'replaceText', + 'insertContent', +]) + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +const isPos = (value: unknown): value is number => + typeof value === 'number' && Number.isInteger(value) && value >= 0 + +/** Keeps only well-formed operations; anything else from the backend is dropped. */ +export const normalizeWysiwygAiOperations = (input: unknown): WysiwygAiOperation[] => { + if (!Array.isArray(input)) return [] + const out: WysiwygAiOperation[] = [] + for (const raw of input) { + if (!isRecord(raw) || typeof raw.content !== 'string') continue + const type = raw.type as WysiwygAiOperation['type'] + if (!OPERATION_TYPES.has(type)) continue + if (type === 'replaceText') { + if (!isPos(raw.from) || !isPos(raw.to) || raw.to < raw.from) continue + out.push({ type, from: raw.from, to: raw.to, content: raw.content }) + } else if (type === 'insertContent') { + if (!isPos(raw.at)) continue + out.push({ type, at: raw.at, content: raw.content }) + } else { + out.push({ type, content: raw.content }) + } + } + return out +} + +const inlineFromLine = (text: string): JSONContent[] => { + const nodes: JSONContent[] = [] + text.split('\n').forEach((line, index) => { + if (index > 0) nodes.push({ type: 'hardBreak' }) + if (line) nodes.push({ type: 'text', text: line }) + }) + return nodes +} + +/** + * Plain text → paragraphs (blank lines) with hard breaks (single newlines). + * Text nodes are never parsed as markup, so model output cannot inject HTML. + */ +export const textToWysiwygParagraphs = (text: string): JSONContent[] => + text + .replace(/\r\n?/g, '\n') + .trim() + .split(/\n{2,}/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean) + .map((paragraph) => ({ type: 'paragraph', content: inlineFromLine(paragraph) })) + +/** Single-paragraph text inserted inline keeps its edge spaces (“ continued…”). */ +const inlineText = (text: string): JSONContent[] | null => { + const normalized = text.replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, '') + if (!normalized.trim() || /\n{2,}/.test(normalized)) return null + return inlineFromLine(normalized) +} + +type InsertMode = 'inline' | 'block' + +const toInsertable = ( + content: string, + format: WysiwygAiOutputFormat, + mode: InsertMode, +): JSONContent[] | string | null => { + if (format === 'html') { + const html = sanitizeWysiwygAiHtml(content).trim() + if (!html) return null + return mode === 'block' && !/^<(p|h[1-6]|ul|ol|pre|blockquote|table|hr)\b/i.test(html) + ? `

${html}

` + : html + } + if (mode === 'inline') { + const inline = inlineText(content) + if (inline) return inline + } + const paragraphs = textToWysiwygParagraphs(content) + return paragraphs.length ? paragraphs : null +} + +/** Resolves the document range an operation writes to, or `null` when it is invalid. */ +const resolveRange = ( + editor: Editor, + operation: WysiwygAiOperation, + target: WysiwygAiTarget, +): { from: number; to: number; mode: InsertMode } | null => { + const size = editor.state.doc.content.size + switch (operation.type) { + case 'replaceSelection': + return { from: target.from, to: target.to, mode: 'inline' } + case 'insertAtCursor': + return { from: target.to, to: target.to, mode: 'inline' } + case 'insertBelow': + return { from: target.blockEnd, to: target.blockEnd, mode: 'block' } + case 'replaceText': + return operation.to <= size ? { ...operation, mode: 'inline' } : null + case 'insertContent': + return operation.at <= size ? { from: operation.at, to: operation.at, mode: 'inline' } : null + } +} + +/** + * Applies one approved operation as a single Tiptap transaction (one undo step). + * Returns `false` when the operation is out of range or has no content. + */ +export const applyWysiwygAiOperation = ( + editor: Editor, + operation: WysiwygAiOperation, + target: WysiwygAiTarget, + format: WysiwygAiOutputFormat = 'text', +): boolean => { + if (editor.isDestroyed || !editor.isEditable) return false + const range = resolveRange(editor, operation, target) + if (!range) return false + + let { from, to } = range + const insertable = toInsertable(operation.content, format, range.mode) + if (insertable == null) return false + + // Several paragraphs replacing a whole block: swap the block node itself instead of + // splitting paragraphs inside it. + if ( + target.kind === 'block' && + operation.type === 'replaceSelection' && + Array.isArray(insertable) && + insertable.every((node) => node.type === 'paragraph') + ) { + from -= 1 + to += 1 + } + + return editor + .chain() + .focus() + .insertContentAt({ from, to }, insertable, { updateSelection: true }) + .run() +} diff --git a/src/advanced/wysiwyg/ai/options.ts b/src/advanced/wysiwyg/ai/options.ts new file mode 100644 index 0000000..d8a3d65 --- /dev/null +++ b/src/advanced/wysiwyg/ai/options.ts @@ -0,0 +1,80 @@ +import { DEFAULT_WYSIWYG_AI_LABELS, resolveWysiwygAiActions } from './actions' +import { DEFAULT_AI_MAX_CONTEXT_CHARACTERS } from './context' +import { createWysiwygAiHttpTransport } from './httpTransport' +import type { + WysiwygAiAction, + WysiwygAiLabels, + WysiwygAiOptions, + WysiwygAiOutputFormat, + WysiwygAiTransport, +} from './types' + +export type ResolvedWysiwygAiOptions = { + /** `true` only when `enabled: true` and a transport (or endpoint) is configured. */ + enabled: boolean + transport: WysiwygAiTransport | null + actions: WysiwygAiAction[] + bubbleMenu: boolean + slashCommands: boolean + stream: boolean + maxContextCharacters: number + includeHtml: boolean + includeJson: boolean + format: WysiwygAiOutputFormat + metadata?: Record + labels: WysiwygAiLabels +} + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +const isTransport = (value: unknown): value is WysiwygAiTransport => + isRecord(value) && typeof value.generate === 'function' + +export const DISABLED_WYSIWYG_AI: ResolvedWysiwygAiOptions = Object.freeze({ + enabled: false, + transport: null, + actions: [], + bubbleMenu: false, + slashCommands: false, + stream: false, + maxContextCharacters: DEFAULT_AI_MAX_CONTEXT_CHARACTERS, + includeHtml: false, + includeJson: false, + format: 'text', + labels: DEFAULT_WYSIWYG_AI_LABELS, +}) as ResolvedWysiwygAiOptions + +/** + * Resolves `options.ai`. Anything but an object with `enabled: true` and a usable + * transport/endpoint yields the inert `DISABLED_WYSIWYG_AI` (no transport is created). + */ +export const resolveWysiwygAiOptions = (raw: unknown): ResolvedWysiwygAiOptions => { + if (!isRecord(raw) || raw.enabled !== true) return DISABLED_WYSIWYG_AI + const opts = raw as WysiwygAiOptions + + const transport = isTransport(opts.transport) + ? opts.transport + : typeof opts.endpoint === 'string' && opts.endpoint + ? createWysiwygAiHttpTransport({ endpoint: opts.endpoint, headers: opts.headers }) + : null + if (!transport) return DISABLED_WYSIWYG_AI + + return { + enabled: true, + transport, + actions: resolveWysiwygAiActions(opts.actions), + bubbleMenu: opts.bubbleMenu !== false, + slashCommands: opts.slashCommands !== false, + stream: opts.stream !== false, + maxContextCharacters: + typeof opts.maxContextCharacters === 'number' && opts.maxContextCharacters >= 0 + ? opts.maxContextCharacters + : DEFAULT_AI_MAX_CONTEXT_CHARACTERS, + includeHtml: opts.includeHtml === true, + includeJson: opts.includeJson === true, + format: opts.format === 'html' ? 'html' : 'text', + ...(isRecord(opts.body) ? { metadata: opts.body } : {}), + labels: { ...DEFAULT_WYSIWYG_AI_LABELS, ...(isRecord(opts.labels) ? opts.labels : {}) }, + } +} diff --git a/src/advanced/wysiwyg/ai/sanitize.ts b/src/advanced/wysiwyg/ai/sanitize.ts new file mode 100644 index 0000000..d2ece3e --- /dev/null +++ b/src/advanced/wysiwyg/ai/sanitize.ts @@ -0,0 +1,135 @@ +/** + * Allowlist HTML sanitizer for AI output. The result is additionally parsed by the + * Tiptap schema on insertion, which drops anything the editor does not model. + * Generated HTML is never rendered with `v-html`. + */ + +const ALLOWED_TAGS = new Set([ + 'p', + 'br', + 'strong', + 'b', + 'em', + 'i', + 'u', + 's', + 'del', + 'code', + 'pre', + 'blockquote', + 'ul', + 'ol', + 'li', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'a', + 'hr', + 'mark', + 'span', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', +]) + +/** Dropped together with their content. */ +const DROPPED_TAGS = new Set([ + 'script', + 'style', + 'iframe', + 'object', + 'embed', + 'frame', + 'frameset', + 'noscript', + 'template', + 'svg', + 'math', + 'link', + 'meta', + 'base', + 'form', + 'input', + 'button', + 'textarea', + 'select', + 'title', + 'head', +]) + +const ALLOWED_ATTRIBUTES: Record = { + a: ['href', 'title'], + td: ['colspan', 'rowspan'], + th: ['colspan', 'rowspan'], + ol: ['start'], +} + +const SAFE_SCHEME = /^(https?|mailto|tel):/i + +/** + * `true` for http(s), mailto, tel, fragment and relative URLs; `false` for any other + * scheme (`javascript:`, `data:`…) and protocol-relative URLs (`//host`). + */ +export const isSafeWysiwygUrl = (url: string): boolean => { + // Browsers ignore ASCII control characters and whitespace inside schemes. + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point. + const compact = url.replace(/[\u0000- \u007f]/g, '') + if (!compact) return false + if (/^[a-z][a-z0-9+.-]*:/i.test(compact)) return SAFE_SCHEME.test(compact) + return !/^[\\/]{2}/.test(compact) +} + +const escapeHtml = (text: string) => + text.replace(/&/g, '&').replace(//g, '>') + +const sanitizeNode = (node: Node, out: Document): Node | DocumentFragment | null => { + if (node.nodeType === 3) return out.createTextNode(node.textContent ?? '') + if (node.nodeType !== 1) return null + + const element = node as Element + const tag = element.tagName.toLowerCase() + if (DROPPED_TAGS.has(tag)) return null + + const children = out.createDocumentFragment() + for (const child of Array.from(element.childNodes)) { + const clean = sanitizeNode(child, out) + if (clean) children.appendChild(clean) + } + if (!ALLOWED_TAGS.has(tag)) return children + + const clean = out.createElement(tag) + for (const name of ALLOWED_ATTRIBUTES[tag] ?? []) { + const value = element.getAttribute(name) + if (value == null) continue + if (name === 'href' && !isSafeWysiwygUrl(value)) continue + if (name !== 'href' && name !== 'title' && !/^\d{1,3}$/.test(value)) continue + clean.setAttribute(name, value) + } + if (tag === 'a' && clean.hasAttribute('href')) clean.setAttribute('rel', 'noopener noreferrer') + clean.appendChild(children) + return clean +} + +/** + * Returns sanitized HTML. Without `DOMParser` (SSR), falls back to escaped text so + * nothing unsafe can be produced. + */ +export const sanitizeWysiwygAiHtml = (html: string): string => { + if (typeof DOMParser === 'undefined') { + return escapeHtml(html.replace(/<[^>]*>/g, '')) + } + const parsed = new DOMParser().parseFromString(html, 'text/html') + const out = document.implementation.createHTMLDocument('') + const container = out.createElement('div') + for (const child of Array.from(parsed.body.childNodes)) { + const clean = sanitizeNode(child, out) + if (clean) container.appendChild(clean) + } + return container.innerHTML +} diff --git a/src/advanced/wysiwyg/ai/stream.ts b/src/advanced/wysiwyg/ai/stream.ts new file mode 100644 index 0000000..2aa1477 --- /dev/null +++ b/src/advanced/wysiwyg/ai/stream.ts @@ -0,0 +1,140 @@ +import { normalizeWysiwygAiOperations } from './operations' +import type { WysiwygAiResponse, WysiwygAiStreamChunk } from './types' + +/** Header set by AI SDK `toUIMessageStreamResponse()` / `createUIMessageStreamResponse()`. */ +export const UI_MESSAGE_STREAM_HEADER = 'x-vercel-ai-ui-message-stream' + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +async function* readChunks(body: ReadableStream): AsyncGenerator { + const reader = body.getReader() + const decoder = new TextDecoder() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + const text = decoder.decode(value, { stream: true }) + if (text) yield text + } + const rest = decoder.decode() + if (rest) yield rest + } finally { + reader.releaseLock() + } +} + +/** Plain text stream — AI SDK `toTextStreamResponse()`. */ +export async function* parseTextStream( + body: ReadableStream, +): AsyncGenerator { + for await (const text of readChunks(body)) { + yield { type: 'text-delta', delta: text } + } +} + +/** Maps one AI SDK UI message stream part to an editor chunk (other parts are ignored). */ +export const uiMessagePartToChunk = (part: unknown): WysiwygAiStreamChunk | undefined => { + if (!isRecord(part)) return undefined + if (part.type === 'text-delta') { + const delta = typeof part.delta === 'string' ? part.delta : part.textDelta + return typeof delta === 'string' && delta ? { type: 'text-delta', delta } : undefined + } + if (part.type === 'error') { + const errorText = typeof part.errorText === 'string' ? part.errorText : 'Stream error' + return { type: 'error', errorText } + } + // Custom data part, e.g. `writer.write({ type: 'data-operations', data: { operations } })`. + if (typeof part.type === 'string' && part.type.startsWith('data-') && isRecord(part.data)) { + const operations = normalizeWysiwygAiOperations(part.data.operations) + if (!operations.length) return undefined + const explanation = + typeof part.data.explanation === 'string' ? part.data.explanation : undefined + return { type: 'operations', operations, ...(explanation ? { explanation } : {}) } + } + return undefined +} + +/** Server-sent events — AI SDK `toUIMessageStreamResponse()` (UI message stream v1). */ +export async function* parseUiMessageStream( + body: ReadableStream, +): AsyncGenerator { + let buffer = '' + let data: string[] = [] + + const flush = function* (): Generator { + if (!data.length) return false + const payload = data.join('\n') + data = [] + if (payload === '[DONE]') return true + let part: unknown + try { + part = JSON.parse(payload) + } catch { + return false + } + const chunk = uiMessagePartToChunk(part) + if (chunk) yield chunk + return false + } + + for await (const text of readChunks(body)) { + buffer += text + let newline = buffer.indexOf('\n') + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, '') + buffer = buffer.slice(newline + 1) + if (line === '') { + if (yield* flush()) return + } else if (line.startsWith('data:')) { + data.push(line.slice(5).replace(/^ /, '')) + } + newline = buffer.indexOf('\n') + } + } + if (buffer.startsWith('data:')) data.push(buffer.slice(5).replace(/^ /, '')) + yield* flush() +} + +/** + * Normalizes a JSON body: `{ text }`, `{ content }`, `{ operation, content }` + * (e.g. AI SDK `Output.object()` / `generateObject()`), or `{ operations: [...] }`. + */ +export const parseWysiwygAiJsonResponse = (body: unknown): WysiwygAiResponse => { + if (typeof body === 'string') return { text: body } + if (!isRecord(body)) return { text: '' } + + const text = + typeof body.text === 'string' ? body.text : typeof body.content === 'string' ? body.content : '' + const explanation = typeof body.explanation === 'string' ? body.explanation : undefined + + let operations = normalizeWysiwygAiOperations(body.operations) + if (!operations.length && typeof body.operation === 'string') { + operations = normalizeWysiwygAiOperations([{ ...body, type: body.operation }]) + } + + return { + text, + ...(operations.length ? { operations } : {}), + ...(explanation ? { explanation } : {}), + } +} + +/** Reads a whole streamed body into a single response. */ +export const collectWysiwygAiStream = async ( + chunks: AsyncIterable, +): Promise => { + let text = '' + const response: WysiwygAiResponse = { text } + for await (const chunk of chunks) { + if (typeof chunk === 'string') text += chunk + else if (chunk.type === 'text-delta') text += chunk.delta + else if (chunk.type === 'error') throw new Error(chunk.errorText) + else { + response.operations = chunk.operations + if (chunk.explanation) response.explanation = chunk.explanation + } + } + response.text = text + return response +} diff --git a/src/advanced/wysiwyg/ai/tools.ts b/src/advanced/wysiwyg/ai/tools.ts new file mode 100644 index 0000000..065d174 --- /dev/null +++ b/src/advanced/wysiwyg/ai/tools.ts @@ -0,0 +1,163 @@ +import type { Editor } from '@tiptap/core' +import { findWysiwygTextRanges } from '../search' +import type { WysiwygAiOperation } from './types' + +/** + * Editor tools for a future AI SDK agent. Shapes mirror AI SDK `tool()` + * (`description`, JSON Schema `inputSchema`, `execute`) without depending on `ai`: + * wrap `inputSchema` with `jsonSchema()` when registering them client-side + * (e.g. `useChat({ onToolCall })`). + * + * - `read` tools only inspect the editor and may run automatically. + * - `propose` tools never mutate: they return a `WysiwygAiOperation` the user must + * approve before it is applied (see `applyWysiwygAiOperation`). + */ +export type WysiwygEditorToolKind = 'read' | 'propose' + +export type WysiwygEditorTool, Output = unknown> = { + kind: WysiwygEditorToolKind + description: string + inputSchema: Record + execute: (input: Input) => Output +} + +const MAX_TOOL_TEXT = 20000 + +const emptySchema = { type: 'object', properties: {}, additionalProperties: false } + +const contentSchema = { + type: 'object', + properties: { content: { type: 'string', description: 'Plain text content.' } }, + required: ['content'], + additionalProperties: false, +} + +const text = (editor: Editor, from: number, to: number) => + editor.state.doc.textBetween(from, to, '\n\n', ' ').slice(0, MAX_TOOL_TEXT) + +export const createWysiwygEditorTools = (editor: Editor) => { + const getSelection: WysiwygEditorTool< + Record, + { from: number; to: number; text: string } + > = { + kind: 'read', + description: 'Get the current selection range and its text.', + inputSchema: emptySchema, + execute: () => { + const { from, to } = editor.state.selection + return { from, to, text: text(editor, from, to) } + }, + } + + const getDocumentText: WysiwygEditorTool, { text: string }> = { + kind: 'read', + description: 'Get the document as plain text.', + inputSchema: emptySchema, + execute: () => ({ text: text(editor, 0, editor.state.doc.content.size) }), + } + + const getCurrentBlock: WysiwygEditorTool< + Record, + { type: string; text: string } + > = { + kind: 'read', + description: 'Get the block containing the cursor.', + inputSchema: emptySchema, + execute: () => { + const parent = editor.state.selection.$from.parent + return { type: parent.type.name, text: parent.textContent.slice(0, MAX_TOOL_TEXT) } + }, + } + + const getHeadings: WysiwygEditorTool< + Record, + Array<{ level: number; text: string; pos: number }> + > = { + kind: 'read', + description: 'List the document headings with their level and position.', + inputSchema: emptySchema, + execute: () => { + const headings: Array<{ level: number; text: string; pos: number }> = [] + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'heading') { + headings.push({ level: Number(node.attrs.level) || 1, text: node.textContent, pos }) + return false + } + return true + }) + return headings + }, + } + + const findText: WysiwygEditorTool< + { query: string; caseSensitive?: boolean }, + Array<{ from: number; to: number }> + > = { + kind: 'read', + description: 'Find every occurrence of a text in the document.', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' }, caseSensitive: { type: 'boolean' } }, + required: ['query'], + additionalProperties: false, + }, + execute: ({ query, caseSensitive }) => + findWysiwygTextRanges(editor.state.doc, query, caseSensitive === true), + } + + const proposeReplaceSelection: WysiwygEditorTool<{ content: string }, WysiwygAiOperation> = { + kind: 'propose', + description: 'Propose replacing the current selection. Requires user approval.', + inputSchema: contentSchema, + execute: ({ content }) => ({ type: 'replaceSelection', content }), + } + + const proposeInsertContent: WysiwygEditorTool< + { content: string; at?: number }, + WysiwygAiOperation + > = { + kind: 'propose', + description: + 'Propose inserting content at a position (default: cursor). Requires user approval.', + inputSchema: { + type: 'object', + properties: { content: { type: 'string' }, at: { type: 'integer', minimum: 0 } }, + required: ['content'], + additionalProperties: false, + }, + execute: ({ content, at }) => + typeof at === 'number' + ? { type: 'insertContent', at, content } + : { type: 'insertAtCursor', content }, + } + + const proposeReplaceText: WysiwygEditorTool< + { from: number; to: number; content: string }, + WysiwygAiOperation + > = { + kind: 'propose', + description: 'Propose replacing a document range (from findText). Requires user approval.', + inputSchema: { + type: 'object', + properties: { + from: { type: 'integer', minimum: 0 }, + to: { type: 'integer', minimum: 0 }, + content: { type: 'string' }, + }, + required: ['from', 'to', 'content'], + additionalProperties: false, + }, + execute: ({ from, to, content }) => ({ type: 'replaceText', from, to, content }), + } + + return { + getSelection, + getDocumentText, + getCurrentBlock, + getHeadings, + findText, + proposeReplaceSelection, + proposeInsertContent, + proposeReplaceText, + } +} diff --git a/src/advanced/wysiwyg/ai/types.ts b/src/advanced/wysiwyg/ai/types.ts new file mode 100644 index 0000000..309076b --- /dev/null +++ b/src/advanced/wysiwyg/ai/types.ts @@ -0,0 +1,159 @@ +import type { JSONContent } from '@tiptap/core' + +/** + * How an AI action relates to the document: + * - `transform` rewrites a target (the selection, or the current block when nothing is selected); + * - `generate` produces new content inserted at the cursor. + */ +export type WysiwygAiActionMode = 'transform' | 'generate' + +/** `auto` resolves to `transform` with a selection and `generate` without. */ +export type WysiwygAiActionModeInput = WysiwygAiActionMode | 'auto' + +export type WysiwygAiAction = { + /** Stable identifier sent to the backend as `request.action`. */ + id: string + label: string + icon?: string + description?: string + /** Provider-agnostic instruction forwarded to the backend. */ + instruction?: string + /** Default: `transform`. */ + mode?: WysiwygAiActionModeInput + /** Ask the user for a free-form instruction before running (e.g. “Ask AI”). */ + promptForInstruction?: boolean +} + +/** A default action id (`'improve'`, …) or a custom action. */ +export type WysiwygAiActionInput = string | WysiwygAiAction + +/** Format the editor expects back from the backend. */ +export type WysiwygAiOutputFormat = 'text' | 'html' + +export type WysiwygAiSelection = { + from: number + to: number +} + +/** + * Payload sent to the consumer backend. It deliberately carries editor context only — + * no provider, model or AI SDK message format. + */ +export type WysiwygAiRequest = { + action: string + instruction?: string + mode: WysiwygAiActionMode + /** Range the result applies to (the selection or the current block). */ + selection?: WysiwygAiSelection + /** Text the action operates on (selection, or current block when nothing is selected). */ + selectedText?: string + /** Text of the block containing the cursor. */ + currentBlock?: string + textBeforeSelection?: string + textAfterSelection?: string + /** Plain-text document, truncated to `maxContextCharacters`. */ + documentText: string + /** `true` when any context field above was truncated. */ + truncated?: boolean + /** Only sent when `ai.includeHtml` is enabled. */ + html?: string + /** Only sent when `ai.includeJson` is enabled. */ + json?: JSONContent + format: WysiwygAiOutputFormat + /** Extra consumer data (`ai.body`), forwarded verbatim. */ + metadata?: Record +} + +/** + * A proposed document edit. Operations are only ever applied after explicit user + * approval — they are the contract a future AI SDK agent (tools) can target. + */ +export type WysiwygAiOperation = + | { type: 'replaceSelection'; content: string } + | { type: 'insertBelow'; content: string } + | { type: 'insertAtCursor'; content: string } + | { type: 'replaceText'; from: number; to: number; content: string } + | { type: 'insertContent'; at: number; content: string } + +export type WysiwygAiResponse = { + text: string + /** Structured edits (e.g. from AI SDK `Output.object()` / `generateObject()`). */ + operations?: WysiwygAiOperation[] + explanation?: string +} + +/** Chunk yielded by `transport.stream()`. A bare string is a text delta. */ +export type WysiwygAiStreamChunk = + | string + | { type: 'text-delta'; delta: string } + | { type: 'operations'; operations: WysiwygAiOperation[]; explanation?: string } + | { type: 'error'; errorText: string } + +export type WysiwygAiTransportCallOptions = { + signal?: AbortSignal +} + +/** + * How AI requests reach the consumer's AI SDK backend. The library never talks to + * a provider and never holds API keys. + */ +export interface WysiwygAiTransport { + generate( + request: WysiwygAiRequest, + options?: WysiwygAiTransportCallOptions, + ): Promise + stream?( + request: WysiwygAiRequest, + options?: WysiwygAiTransportCallOptions, + ): AsyncIterable +} + +/** UI strings of the AI menu and result panel (French by default). */ +export type WysiwygAiLabels = { + menu: string + ask: string + instructionPlaceholder: string + submit: string + generating: string + done: string + error: string + replaceSelection: string + replaceBlock: string + insert: string + insertBelow: string + apply: string + retry: string + stop: string + discard: string + empty: string +} + +/** `options.ai` of the WYSIWYG renderer. Nothing AI-related runs unless `enabled: true`. */ +export type WysiwygAiOptions = { + enabled?: boolean + /** Backend endpoint consumed by the default HTTP transport. */ + endpoint?: string + /** Custom transport; takes precedence over `endpoint`. */ + transport?: WysiwygAiTransport + /** Default ids and/or custom actions. Default: `DEFAULT_WYSIWYG_AI_ACTIONS`. */ + actions?: WysiwygAiActionInput[] + /** AI entry in the text bubble menu. Default: `true`. */ + bubbleMenu?: boolean + /** AI entries in the `/` palette. Default: `true`. */ + slashCommands?: boolean + /** Prefer `transport.stream()` when available. Default: `true`. */ + stream?: boolean + /** Budget for document context (before/after/document text). Default: `12000`. */ + maxContextCharacters?: number + /** Send the document HTML. Default: `false`. */ + includeHtml?: boolean + /** Send the document Tiptap JSON. Default: `false`. */ + includeJson?: boolean + /** Expected output format. `html` is sanitized before insertion. Default: `text`. */ + format?: WysiwygAiOutputFormat + /** Extra headers for the default HTTP transport (no provider secrets!). */ + headers?: Record + /** Extra data merged into `request.metadata`. */ + body?: Record + labels?: Partial +} diff --git a/src/advanced/wysiwyg/ai/useWysiwygAi.ts b/src/advanced/wysiwyg/ai/useWysiwygAi.ts new file mode 100644 index 0000000..1c88f79 --- /dev/null +++ b/src/advanced/wysiwyg/ai/useWysiwygAi.ts @@ -0,0 +1,285 @@ +import type { Editor, EditorEvents } from '@tiptap/core' +import { computed, ref, shallowRef, type Ref } from 'vue' +import { buildWysiwygAiRequest, resolveWysiwygAiTarget, type WysiwygAiTarget } from './context' +import { applyWysiwygAiOperation } from './operations' +import type { ResolvedWysiwygAiOptions } from './options' +import type { WysiwygAiAction, WysiwygAiOperation, WysiwygAiRequest } from './types' + +export type WysiwygAiStatus = 'idle' | 'prompting' | 'generating' | 'done' | 'stopped' | 'error' + +/** + * How to apply the result: + * - `replace`: replace the target (selection or current block); + * - `insert`: insert at the cursor; + * - `insertBelow`: insert after the block containing the target; + * - `operations`: apply the structured operations returned by the backend. + */ +export type WysiwygAiAcceptMode = 'replace' | 'insert' | 'insertBelow' | 'operations' + +export type UseWysiwygAiOptions = { + getEditor: () => Editor | null | undefined + options: Ref +} + +type TrackedTarget = { + target: WysiwygAiTarget + /** Set once any transaction changed the document after the request was built. */ + docChanged: boolean + /** Set when the target range itself was deleted. */ + lost: boolean +} + +const STALE_MESSAGE = 'Le texte ciblé a été modifié pendant la génération.' + +const isAbortError = (error: unknown) => + error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError') + +/** + * AI orchestration for one editor: request building, streaming into a detached + * preview, abort / retry, and explicit acceptance. The document is never touched + * before `accept()`. + */ +export const useWysiwygAi = ({ getEditor, options }: UseWysiwygAiOptions) => { + const status = ref('idle') + const currentAction = shallowRef(null) + const instruction = ref('') + const result = ref('') + const operations = shallowRef([]) + const explanation = ref('') + const error = ref(null) + const lastRequest = shallowRef(null) + const tracked = shallowRef(null) + + let controller: AbortController | null = null + let runId = 0 + let detachTracking: (() => void) | null = null + + const isGenerating = computed(() => status.value === 'generating') + const isOpen = computed(() => status.value !== 'idle') + const target = computed(() => tracked.value?.target ?? null) + + const canRun = () => { + const editor = getEditor() + return !!editor && !editor.isDestroyed && editor.isEditable && options.value.enabled + } + + const stopTracking = () => { + detachTracking?.() + detachTracking = null + } + + const track = (editor: Editor, initial: WysiwygAiTarget) => { + stopTracking() + const state: TrackedTarget = { target: { ...initial }, docChanged: false, lost: false } + const onTransaction = ({ transaction }: EditorEvents['transaction']) => { + if (!transaction.docChanged) return + const { mapping } = transaction + const from = mapping.mapResult(state.target.from, 1) + const to = mapping.mapResult(state.target.to, -1) + const blockEnd = mapping.mapResult(state.target.blockEnd, -1) + state.docChanged = true + if (state.target.kind !== 'cursor' && from.deleted && to.deleted) state.lost = true + state.target = { + ...state.target, + from: from.pos, + to: Math.max(from.pos, to.pos), + blockEnd: blockEnd.pos, + } + tracked.value = { ...state } + } + editor.on('transaction', onTransaction) + detachTracking = () => editor.off('transaction', onTransaction) + tracked.value = { ...state } + } + + const abort = () => { + controller?.abort() + controller = null + } + + const reset = () => { + result.value = '' + operations.value = [] + explanation.value = '' + error.value = null + } + + const run = async (request: WysiwygAiRequest) => { + const transport = options.value.transport + if (!transport) return + abort() + reset() + const id = ++runId + const current = new AbortController() + controller = current + status.value = 'generating' + lastRequest.value = request + + try { + if (options.value.stream && typeof transport.stream === 'function') { + for await (const chunk of transport.stream(request, { signal: current.signal })) { + if (id !== runId || current.signal.aborted) return + if (typeof chunk === 'string') result.value += chunk + else if (chunk.type === 'text-delta') result.value += chunk.delta + else if (chunk.type === 'error') throw new Error(chunk.errorText) + else { + operations.value = chunk.operations + explanation.value = chunk.explanation ?? '' + } + } + } else { + const response = await transport.generate(request, { signal: current.signal }) + if (id !== runId || current.signal.aborted) return + result.value = response.text ?? '' + operations.value = response.operations ?? [] + explanation.value = response.explanation ?? '' + } + if (id === runId) status.value = 'done' + } catch (err) { + if (id !== runId) return + if (current.signal.aborted || isAbortError(err)) { + status.value = 'stopped' + return + } + error.value = err instanceof Error && err.message ? err.message : String(err) + status.value = 'error' + } finally { + if (controller === current) controller = null + } + } + + /** Opens an action: asks for an instruction first when the action needs one. */ + const open = (action: WysiwygAiAction) => { + if (!canRun()) return + if (action.promptForInstruction) { + abort() + reset() + currentAction.value = action + instruction.value = '' + status.value = 'prompting' + return + } + void start(action) + } + + /** Builds the request from the current selection and starts generating. */ + const start = async (action: WysiwygAiAction, userInstruction?: string) => { + const editor = getEditor() + if (!editor || !canRun()) return + currentAction.value = action + if (userInstruction !== undefined) instruction.value = userInstruction + const initial = resolveWysiwygAiTarget(editor, action) + track(editor, initial) + const cfg = options.value + const request = buildWysiwygAiRequest(editor, action, initial, { + instruction: instruction.value, + maxContextCharacters: cfg.maxContextCharacters, + includeHtml: cfg.includeHtml, + includeJson: cfg.includeJson, + format: cfg.format, + metadata: cfg.metadata, + }) + await run(request) + } + + /** Replays the last request (same target and context). */ + const retry = async () => { + if (!lastRequest.value || !canRun()) return + await run(lastRequest.value) + } + + /** Stops generating; the partial result stays available. */ + const cancel = () => { + if (status.value !== 'generating') return + runId++ + abort() + status.value = 'stopped' + } + + const discard = () => { + runId++ + abort() + stopTracking() + reset() + tracked.value = null + currentAction.value = null + lastRequest.value = null + instruction.value = '' + status.value = 'idle' + } + + /** Writes the result into the document. Returns `true` when the document changed. */ + const accept = (mode: WysiwygAiAcceptMode): boolean => { + const editor = getEditor() + const state = tracked.value + if (!editor || !state || !canRun()) return false + if (status.value !== 'done' && status.value !== 'stopped') return false + + const { target: current } = state + if (state.lost) { + error.value = STALE_MESSAGE + return false + } + if (state.docChanged && current.kind !== 'cursor' && mode === 'replace') { + const text = editor.state.doc.textBetween(current.from, current.to, '\n\n', ' ') + if (text !== current.text) { + error.value = STALE_MESSAGE + return false + } + } + + let applied = false + if (mode === 'operations') { + // Absolute positions are only valid against the document the backend saw. + if (state.docChanged) { + error.value = STALE_MESSAGE + return false + } + const absolute = (op: WysiwygAiOperation) => + op.type === 'replaceText' ? op.from : op.type === 'insertContent' ? op.at : -1 + const ordered = [...operations.value].sort((a, b) => absolute(b) - absolute(a)) + for (const op of ordered) { + // Tracking keeps the target mapped across the operations applied so far. + const latest = tracked.value?.target ?? current + applied = applyWysiwygAiOperation(editor, op, latest, options.value.format) || applied + } + } else { + const content = result.value + const type = + mode === 'replace' + ? 'replaceSelection' + : mode === 'insert' + ? 'insertAtCursor' + : 'insertBelow' + applied = applyWysiwygAiOperation(editor, { type, content }, current, options.value.format) + } + + if (applied) discard() + return applied + } + + const dispose = () => discard() + + return { + status, + currentAction, + instruction, + result, + operations, + explanation, + error, + target, + lastRequest, + isGenerating, + isOpen, + open, + start, + retry, + cancel, + accept, + discard, + dispose, + } +} + +export type WysiwygAiController = ReturnType diff --git a/src/advanced/wysiwyg/colors.ts b/src/advanced/wysiwyg/colors.ts new file mode 100644 index 0000000..fe4ad76 --- /dev/null +++ b/src/advanced/wysiwyg/colors.ts @@ -0,0 +1,27 @@ +export type WysiwygColor = { + label: string + /** CSS color stored in the document (inline style). */ + value: string + /** Static Tailwind class used for the swatch icon in menus. */ + swatch: string +} + +export const WYSIWYG_TEXT_COLORS: readonly WysiwygColor[] = Object.freeze([ + { label: 'Gris', value: '#6b7280', swatch: 'text-gray-500' }, + { label: 'Rouge', value: '#dc2626', swatch: 'text-red-600' }, + { label: 'Orange', value: '#ea580c', swatch: 'text-orange-600' }, + { label: 'Jaune', value: '#ca8a04', swatch: 'text-yellow-600' }, + { label: 'Vert', value: '#16a34a', swatch: 'text-green-600' }, + { label: 'Bleu', value: '#2563eb', swatch: 'text-blue-600' }, + { label: 'Violet', value: '#9333ea', swatch: 'text-purple-600' }, + { label: 'Rose', value: '#db2777', swatch: 'text-pink-600' }, +]) + +export const WYSIWYG_HIGHLIGHT_COLORS: readonly WysiwygColor[] = Object.freeze([ + { label: 'Jaune', value: '#fef08a', swatch: 'text-yellow-200' }, + { label: 'Vert', value: '#bbf7d0', swatch: 'text-green-200' }, + { label: 'Bleu', value: '#bfdbfe', swatch: 'text-blue-200' }, + { label: 'Violet', value: '#e9d5ff', swatch: 'text-purple-200' }, + { label: 'Rose', value: '#fbcfe8', swatch: 'text-pink-200' }, + { label: 'Orange', value: '#fed7aa', swatch: 'text-orange-200' }, +]) diff --git a/src/advanced/wysiwyg/extensions.ts b/src/advanced/wysiwyg/extensions.ts new file mode 100644 index 0000000..d6a3daa --- /dev/null +++ b/src/advanced/wysiwyg/extensions.ts @@ -0,0 +1,44 @@ +import type { AnyExtension } from '@tiptap/core' +import Highlight from '@tiptap/extension-highlight' +import { TaskItem, TaskList } from '@tiptap/extension-list' +import { TableKit } from '@tiptap/extension-table' +import TextAlign from '@tiptap/extension-text-align' +import { Color, TextStyle } from '@tiptap/extension-text-style' +import type { ResolvedWysiwygFeatures } from './features' +import { WysiwygSearch } from './search' + +/** Names a host extension may already register; the host's version always wins. */ +const CONFLICTS: Record = { + taskList: ['taskList'], + taskItem: ['taskItem'], + textAlign: ['textAlign'], + textStyle: ['textStyle', 'textStyleKit'], + color: ['color', 'textStyleKit'], + highlight: ['highlight'], + tableKit: ['tableKit', 'table', 'tableRow', 'tableCell', 'tableHeader'], + wysiwygSearch: ['wysiwygSearch'], +} + +/** + * Built-in feature extensions, minus any whose name the host already provides through + * `options.extensions` (duplicated keyed ProseMirror plugins would throw). + */ +export const buildWysiwygFeatureExtensions = ( + features: ResolvedWysiwygFeatures, + hostExtensions: readonly AnyExtension[] = [], +): AnyExtension[] => { + const taken = new Set(hostExtensions.map((extension) => extension.name)) + const candidates: AnyExtension[] = [] + + if (features.taskList) candidates.push(TaskList, TaskItem.configure({ nested: true })) + if (features.textAlign) candidates.push(TextAlign.configure({ types: ['heading', 'paragraph'] })) + if (features.textColor || features.highlight) candidates.push(TextStyle) + if (features.textColor) candidates.push(Color) + if (features.highlight) candidates.push(Highlight.configure({ multicolor: true })) + if (features.table) candidates.push(TableKit.configure({ table: { resizable: false } })) + if (features.findReplace) candidates.push(WysiwygSearch) + + return candidates.filter( + (extension) => !(CONFLICTS[extension.name] ?? [extension.name]).some((name) => taken.has(name)), + ) +} diff --git a/src/advanced/wysiwyg/features.ts b/src/advanced/wysiwyg/features.ts new file mode 100644 index 0000000..20747b5 --- /dev/null +++ b/src/advanced/wysiwyg/features.ts @@ -0,0 +1,55 @@ +/** + * Editor capabilities layered on top of `UEditor`'s StarterKit. Each feature registers + * its Tiptap extension(s) and default toolbar entries; `false` removes both. + */ +export type WysiwygFeatures = { + /** Task lists (`taskList` toolbar button). Default: `true`. */ + taskList?: boolean + /** Paragraph / heading alignment (`textAlign` toolbar buttons). Default: `true`. */ + textAlign?: boolean + /** Tables, insertion and the contextual table bubble. Default: `true`. */ + table?: boolean + /** Text colors (`textStyle` + `color`). Default: `true`. */ + textColor?: boolean + /** Multicolor highlights. Default: `true`. */ + highlight?: boolean + /** Inline link editor replacing the browser `prompt()`. Default: `true`. */ + linkEditor?: boolean + /** Find & replace panel. Default: `true`. */ + findReplace?: boolean + /** Raw HTML / JSON source editing. Default: `true`. */ + sourceMode?: boolean + /** Read-only preview toggle. Default: `true`. */ + preview?: boolean +} + +export type ResolvedWysiwygFeatures = Required + +export const DEFAULT_WYSIWYG_FEATURES: Readonly = Object.freeze({ + taskList: true, + textAlign: true, + table: true, + textColor: true, + highlight: true, + linkEditor: true, + findReplace: true, + sourceMode: true, + preview: true, +}) + +/** `false` disables every feature; an object overrides individual defaults. */ +export const resolveWysiwygFeatures = (raw: unknown): ResolvedWysiwygFeatures => { + if (raw === false) { + return Object.fromEntries( + Object.keys(DEFAULT_WYSIWYG_FEATURES).map((key) => [key, false]), + ) as ResolvedWysiwygFeatures + } + const resolved = { ...DEFAULT_WYSIWYG_FEATURES } + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + for (const key of Object.keys(resolved) as Array) { + const value = (raw as Record)[key] + if (typeof value === 'boolean') resolved[key] = value + } + } + return resolved +} diff --git a/src/advanced/wysiwyg/handlers.ts b/src/advanced/wysiwyg/handlers.ts new file mode 100644 index 0000000..2039a22 --- /dev/null +++ b/src/advanced/wysiwyg/handlers.ts @@ -0,0 +1,175 @@ +import type { Editor } from '@tiptap/vue-3' +import type { WysiwygAiController } from './ai/useWysiwygAi' +import type { WysiwygAiAction } from './ai/types' + +/** Panels and modes driven by toolbar / slash / bubble items. */ +export type WysiwygUiState = { + panel: null | 'find' | 'link' + mode: 'edit' | 'source' | 'preview' +} + +/** Same contract as Nuxt UI's `EditorHandler` (not re-exported by `@nuxt/ui`). */ +export type WysiwygEditorHandler = { + canExecute: (editor: Editor, cmd?: Record) => boolean + execute: (editor: Editor, cmd?: Record) => { run: () => boolean } + isActive: (editor: Editor, cmd?: Record) => boolean + isDisabled?: (editor: Editor, cmd?: Record) => boolean +} + +export const WYSIWYG_TABLE_ACTIONS = [ + 'addRowBefore', + 'addRowAfter', + 'deleteRow', + 'addColumnBefore', + 'addColumnAfter', + 'deleteColumn', + 'mergeOrSplit', + 'toggleHeaderRow', + 'toggleHeaderColumn', + 'deleteTable', +] as const + +export type WysiwygTableAction = (typeof WYSIWYG_TABLE_ACTIONS)[number] + +const hasExtension = (editor: Editor, name: string) => + editor.extensionManager.extensions.some((extension) => extension.name === name) + +const inImageOrCode = (editor: Editor) => editor.isActive('image') || editor.isActive('codeBlock') + +const color = (cmd?: Record) => + typeof cmd?.color === 'string' && cmd.color ? cmd.color : undefined + +/** Chain-shaped no-op returned by handlers that only change UI state. */ +const done = (editor: Editor) => editor.chain() + +type CreateWysiwygHandlersOptions = { + ui: WysiwygUiState + linkEditor: boolean + ai?: WysiwygAiController | null + aiActions?: WysiwygAiAction[] +} + +/** + * Handlers backing the `kind`s used by the built-in toolbar, bubbles and slash menu. + * Host `options.handlers` are merged after these and can override any of them. + */ +export const createWysiwygHandlers = ({ + ui, + linkEditor, + ai, + aiActions = [], +}: CreateWysiwygHandlersOptions): Record => { + const handlers: Record = { + textColor: { + canExecute: (editor, cmd) => { + const value = color(cmd) + return value ? editor.can().setColor(value) : editor.can().unsetColor() + }, + execute: (editor, cmd) => { + const value = color(cmd) + const chain = editor.chain().focus() + return value ? chain.setColor(value) : chain.unsetColor() + }, + isActive: (editor, cmd) => { + const value = color(cmd) + return !!value && editor.isActive('textStyle', { color: value }) + }, + isDisabled: (editor) => !hasExtension(editor, 'color') || inImageOrCode(editor), + }, + highlight: { + canExecute: (editor, cmd) => { + const value = color(cmd) + return value + ? editor.can().toggleHighlight({ color: value }) + : editor.can().unsetHighlight() + }, + execute: (editor, cmd) => { + const value = color(cmd) + const chain = editor.chain().focus() + return value ? chain.toggleHighlight({ color: value }) : chain.unsetHighlight() + }, + isActive: (editor, cmd) => { + const value = color(cmd) + return value ? editor.isActive('highlight', { color: value }) : false + }, + isDisabled: (editor) => !hasExtension(editor, 'highlight') || inImageOrCode(editor), + }, + table: { + canExecute: (editor) => editor.can().insertTable({ rows: 3, cols: 3, withHeaderRow: true }), + execute: (editor) => + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }), + isActive: (editor) => editor.isActive('table'), + isDisabled: (editor) => !hasExtension(editor, 'table') || inImageOrCode(editor), + }, + tableAction: { + canExecute: (editor, cmd) => { + const action = cmd?.action as WysiwygTableAction | undefined + if (!action || !WYSIWYG_TABLE_ACTIONS.includes(action)) return false + return editor.can()[action]() + }, + execute: (editor, cmd) => { + const action = cmd?.action as WysiwygTableAction | undefined + const chain = editor.chain().focus() + return action && WYSIWYG_TABLE_ACTIONS.includes(action) ? chain[action]() : chain + }, + isActive: () => false, + isDisabled: (editor) => !editor.isActive('table'), + }, + findReplace: { + canExecute: (editor) => hasExtension(editor, 'wysiwygSearch'), + execute: (editor) => { + ui.panel = ui.panel === 'find' ? null : 'find' + return done(editor) + }, + isActive: () => ui.panel === 'find', + }, + sourceMode: { + canExecute: () => true, + execute: (editor) => { + ui.panel = null + ui.mode = ui.mode === 'source' ? 'edit' : 'source' + return done(editor) + }, + isActive: () => ui.mode === 'source', + }, + preview: { + canExecute: () => true, + execute: (editor) => { + ui.panel = null + ui.mode = ui.mode === 'preview' ? 'edit' : 'preview' + return done(editor) + }, + isActive: () => ui.mode === 'preview', + }, + } + + if (linkEditor) { + handlers.link = { + canExecute: (editor) => !editor.state.selection.empty || editor.isActive('link'), + execute: (editor) => { + ui.panel = ui.panel === 'link' ? null : 'link' + return done(editor) + }, + isActive: (editor) => editor.isActive('link'), + isDisabled: (editor) => !hasExtension(editor, 'link') || editor.isActive('image'), + } + } + + if (ai) { + const find = (cmd?: Record) => + aiActions.find((action) => action.id === cmd?.action) + handlers.ai = { + canExecute: (editor, cmd) => editor.isEditable && !!find(cmd), + execute: (editor, cmd) => { + const action = find(cmd) + if (action) ai.open(action) + return done(editor) + }, + // Never "active": Nuxt UI would relabel the AI dropdown with the running action. + isActive: () => false, + isDisabled: () => ai.isGenerating.value, + } + } + + return handlers +} diff --git a/src/advanced/wysiwyg/search.ts b/src/advanced/wysiwyg/search.ts new file mode 100644 index 0000000..022c6b0 --- /dev/null +++ b/src/advanced/wysiwyg/search.ts @@ -0,0 +1,195 @@ +import { Extension, type Editor } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' + +export type WysiwygTextRange = { from: number; to: number } + +export type WysiwygSearchState = { + term: string + caseSensitive: boolean + results: WysiwygTextRange[] + /** Index of the current match in `results` (`-1` when there is none). */ + current: number +} + +type SearchMeta = Partial> + +/** Inline leaves (hard breaks, mentions…) count as one character so offsets stay 1:1. */ +const LEAF_PLACEHOLDER = '' + +/** + * Finds `term` inside each text block, across mark boundaries. Positions are document + * positions usable as a ProseMirror range. + */ +export const findWysiwygTextRanges = ( + doc: ProseMirrorNode, + term: string, + caseSensitive = false, +): WysiwygTextRange[] => { + if (!term) return [] + const needle = caseSensitive ? term : term.toLocaleLowerCase() + const ranges: WysiwygTextRange[] = [] + + doc.descendants((node, pos) => { + if (!node.isTextblock) return true + const start = pos + 1 + const raw = doc.textBetween(start, start + node.content.size, undefined, LEAF_PLACEHOLDER) + const haystack = caseSensitive ? raw : raw.toLocaleLowerCase() + // `toLocaleLowerCase` can change length for a few scripts: fall back to exact case. + const source = haystack.length === raw.length ? haystack : raw + const query = haystack.length === raw.length ? needle : term + let index = source.indexOf(query) + while (index >= 0) { + ranges.push({ from: start + index, to: start + index + query.length }) + index = source.indexOf(query, index + Math.max(query.length, 1)) + } + return false + }) + + return ranges +} + +export const wysiwygSearchKey = new PluginKey('wysiwygSearch') + +const EMPTY_STATE: WysiwygSearchState = { term: '', caseSensitive: false, results: [], current: -1 } + +const recompute = (doc: ProseMirrorNode, state: WysiwygSearchState): WysiwygSearchState => { + const results = findWysiwygTextRanges(doc, state.term, state.caseSensitive) + const current = results.length ? Math.min(Math.max(state.current, 0), results.length - 1) : -1 + return { ...state, results, current } +} + +declare module '@tiptap/core' { + interface Commands { + wysiwygSearch: { + setSearchTerm: (term: string) => ReturnType + setSearchCaseSensitive: (caseSensitive: boolean) => ReturnType + nextSearchResult: () => ReturnType + previousSearchResult: () => ReturnType + replaceSearchResult: (replacement: string) => ReturnType + replaceAllSearchResults: (replacement: string) => ReturnType + } + } +} + +/** Current search state of an editor (empty when the extension is not installed). */ +export const getWysiwygSearchState = (state: EditorState): WysiwygSearchState => + wysiwygSearchKey.getState(state) ?? EMPTY_STATE + +/** + * Find & replace: highlights matches with decorations (never stored in the document) + * and exposes navigation / replace commands, each replace being one undo step. + */ +export const WysiwygSearch = Extension.create({ + name: 'wysiwygSearch', + + addCommands() { + const update = + (meta: SearchMeta) => + ({ tr, dispatch }: { tr: Transaction; dispatch?: (tr: Transaction) => void }) => { + if (dispatch) dispatch(tr.setMeta(wysiwygSearchKey, meta)) + return true + } + + const step = + (direction: 1 | -1) => + ({ + state, + tr, + dispatch, + }: { + state: EditorState + tr: Transaction + dispatch?: (tr: Transaction) => void + }) => { + const search = getWysiwygSearchState(state) + if (!search.results.length) return false + const total = search.results.length + const current = (search.current + direction + total) % total + if (dispatch) dispatch(tr.setMeta(wysiwygSearchKey, { current })) + return true + } + + return { + setSearchTerm: (term: string) => update({ term, current: 0 }), + setSearchCaseSensitive: (caseSensitive: boolean) => update({ caseSensitive, current: 0 }), + nextSearchResult: () => step(1), + previousSearchResult: () => step(-1), + replaceSearchResult: + (replacement: string) => + ({ state, tr, dispatch }) => { + const search = getWysiwygSearchState(state) + const range = search.results[search.current] + if (!range) return false + if (dispatch) { + if (replacement) tr.insertText(replacement, range.from, range.to) + else tr.delete(range.from, range.to) + dispatch(tr) + } + return true + }, + replaceAllSearchResults: + (replacement: string) => + ({ state, tr, dispatch }) => { + const { results } = getWysiwygSearchState(state) + if (!results.length) return false + if (dispatch) { + for (const range of [...results].reverse()) { + if (replacement) tr.insertText(replacement, range.from, range.to) + else tr.delete(range.from, range.to) + } + dispatch(tr) + } + return true + }, + } + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: wysiwygSearchKey, + state: { + init: () => EMPTY_STATE, + apply(tr, value, _old, next) { + const meta = tr.getMeta(wysiwygSearchKey) as SearchMeta | undefined + if (meta) return recompute(next.doc, { ...value, ...meta }) + return tr.docChanged && value.term ? recompute(next.doc, value) : value + }, + }, + props: { + decorations(state) { + const search = wysiwygSearchKey.getState(state) + if (!search?.results.length) return DecorationSet.empty + return DecorationSet.create( + state.doc, + search.results.map((range, index) => + Decoration.inline(range.from, range.to, { + class: + index === search.current + ? 'jf-wysiwyg-search-match jf-wysiwyg-search-match-current' + : 'jf-wysiwyg-search-match', + }), + ), + ) + }, + }, + }), + ] + }, +}) + +/** Scrolls the current match into view (DOM side effect kept out of the plugin). */ +export const scrollToCurrentSearchResult = (editor: Editor) => { + const { results, current } = getWysiwygSearchState(editor.state) + const range = results[current] + if (!range || editor.isDestroyed) return + try { + const { node } = editor.view.domAtPos(range.from) + const element = node instanceof Element ? node : node.parentElement + element?.scrollIntoView?.({ block: 'nearest' }) + } catch { + // Position not rendered (e.g. editor hidden): nothing to scroll. + } +} diff --git a/src/advanced/wysiwyg/slash/commands.ts b/src/advanced/wysiwyg/slash/commands.ts new file mode 100644 index 0000000..2d9fa32 --- /dev/null +++ b/src/advanced/wysiwyg/slash/commands.ts @@ -0,0 +1,72 @@ +import type { WysiwygAiAction } from '../ai/types' +import type { ResolvedWysiwygFeatures } from '../features' + +type Item = Record + +/** Fields the `/` palette filters on — `keywords` lets `/ai` match every AI entry. */ +export const WYSIWYG_SLASH_FILTER_FIELDS = ['label', 'description', 'keywords'] + +type BuildSlashOptions = { + formatting: boolean + features: ResolvedWysiwygFeatures + imagesEnabled: boolean + aiActions?: WysiwygAiAction[] + aiLabel?: string +} + +/** Items for Nuxt UI `UEditorSuggestionMenu`, grouped (one array per group). */ +export const buildWysiwygSlashItems = ({ + formatting, + features, + imagesEnabled, + aiActions = [], + aiLabel = 'IA', +}: BuildSlashOptions): Item[][] => { + const groups: Item[][] = [] + + if (aiActions.length) { + groups.push([ + { type: 'label', label: aiLabel }, + ...aiActions.map((action) => ({ + kind: 'ai', + action: action.id, + label: action.label, + icon: action.icon ?? 'i-lucide-sparkles', + description: action.description, + keywords: 'ai ia assistant', + })), + ]) + } + + if (formatting) { + groups.push([ + { type: 'label', label: 'Texte' }, + { kind: 'paragraph', label: 'Paragraphe', icon: 'i-lucide-pilcrow' }, + { kind: 'heading', level: 1, label: 'Titre 1', icon: 'i-lucide-heading-1' }, + { kind: 'heading', level: 2, label: 'Titre 2', icon: 'i-lucide-heading-2' }, + { kind: 'heading', level: 3, label: 'Titre 3', icon: 'i-lucide-heading-3' }, + ]) + + const lists: Item[] = [ + { type: 'label', label: 'Listes' }, + { kind: 'bulletList', label: 'Liste à puces', icon: 'i-lucide-list' }, + { kind: 'orderedList', label: 'Liste numérotée', icon: 'i-lucide-list-ordered' }, + ] + if (features.taskList) { + lists.push({ kind: 'taskList', label: 'Liste de tâches', icon: 'i-lucide-list-checks' }) + } + groups.push(lists) + + const blocks: Item[] = [ + { type: 'label', label: 'Insérer' }, + { kind: 'blockquote', label: 'Citation', icon: 'i-lucide-quote' }, + { kind: 'codeBlock', label: 'Bloc de code', icon: 'i-lucide-square-code' }, + { kind: 'horizontalRule', label: 'Filet horizontal', icon: 'i-lucide-minus' }, + ] + if (features.table) blocks.push({ kind: 'table', label: 'Tableau', icon: 'i-lucide-table' }) + if (imagesEnabled) blocks.push({ kind: 'imageUpload', label: 'Image', icon: 'i-lucide-image' }) + groups.push(blocks) + } + + return groups +} diff --git a/src/advanced/wysiwyg/source.ts b/src/advanced/wysiwyg/source.ts new file mode 100644 index 0000000..8539671 --- /dev/null +++ b/src/advanced/wysiwyg/source.ts @@ -0,0 +1,31 @@ +import type { Editor, JSONContent } from '@tiptap/core' + +export type WysiwygSourceParseResult = + | { ok: true; content: string | JSONContent } + | { ok: false; error: string } + +export const serializeWysiwygSource = (editor: Editor, contentType: 'html' | 'json'): string => + contentType === 'json' ? JSON.stringify(editor.getJSON(), null, 2) : editor.getHTML() + +/** Validates source-mode input; JSON must be a Tiptap `doc` node. */ +export const parseWysiwygSource = ( + source: string, + contentType: 'html' | 'json', +): WysiwygSourceParseResult => { + if (contentType === 'html') return { ok: true, content: source } + let parsed: unknown + try { + parsed = JSON.parse(source) + } catch (error) { + return { ok: false, error: `JSON invalide : ${(error as Error).message}` } + } + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + (parsed as JSONContent).type !== 'doc' + ) { + return { ok: false, error: 'Le JSON doit être un nœud Tiptap de type « doc ».' } + } + return { ok: true, content: parsed as JSONContent } +} diff --git a/src/advanced/wysiwyg/styles.ts b/src/advanced/wysiwyg/styles.ts new file mode 100644 index 0000000..a4933e2 --- /dev/null +++ b/src/advanced/wysiwyg/styles.ts @@ -0,0 +1,101 @@ +/** + * Nuxt UI's editor theme does not style tables, task lists or search matches. Scoped + * to `.ProseMirror` and built on Nuxt UI CSS variables so the host theme applies. + */ +const STYLE_ID = 'jf-wysiwyg-editor' + +const EDITOR_CSS = ` +.ProseMirror table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + overflow: hidden; +} +.ProseMirror th, +.ProseMirror td { + position: relative; + min-width: 4rem; + border: 1px solid var(--ui-border-accented); + padding: 0.25rem 0.5rem; + vertical-align: top; + text-align: start; +} +.ProseMirror th { + background: var(--ui-bg-elevated); + font-weight: 600; +} +.ProseMirror th > *, +.ProseMirror td > * { + margin: 0 !important; +} +.ProseMirror .selectedCell::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: color-mix(in oklab, var(--ui-primary) 15%, transparent); +} +.ProseMirror .tableWrapper { + overflow-x: auto; +} +/* Nuxt UI sets \`[&_ul]:list-disc\` as an important utility: only a more specific + important rule in the same layer can override it. */ +@layer utilities { + .ProseMirror ul[data-type='taskList'] { + list-style: none !important; + padding-inline-start: 0 !important; + } +} +.ProseMirror ul[data-type='taskList'] li { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding-inline-start: 0; +} +.ProseMirror ul[data-type='taskList'] li > label { + flex: none; + margin-top: 0.2rem; + user-select: none; +} +.ProseMirror ul[data-type='taskList'] li > div { + flex: 1 1 auto; + min-width: 0; +} +.ProseMirror ul[data-type='taskList'] li[data-checked='true'] > div { + color: var(--ui-text-muted); + text-decoration: line-through; +} +.ProseMirror ul[data-type='taskList'] input[type='checkbox'] { + accent-color: var(--ui-primary); +} +.ProseMirror mark { + border-radius: 0.125rem; + padding: 0 0.125rem; + color: inherit; +} +.ProseMirror .jf-wysiwyg-search-match { + background: color-mix(in oklab, var(--ui-warning) 30%, transparent); + border-radius: 0.125rem; +} +.ProseMirror .jf-wysiwyg-search-match-current { + background: color-mix(in oklab, var(--ui-warning) 60%, transparent); + outline: 1px solid var(--ui-warning); +} +` + +/** Idempotent: safe to call from every WYSIWYG instance. */ +export const ensureWysiwygEditorStyles = () => { + if (typeof document === 'undefined') return + if (document.getElementById(STYLE_ID)) return + const style = document.createElement('style') + style.id = STYLE_ID + style.textContent = EDITOR_CSS + document.head.appendChild(style) +} + +/** Replace injected styles after HMR / style tweaks in the same session. */ +export const refreshWysiwygEditorStyles = () => { + if (typeof document === 'undefined') return + document.getElementById(STYLE_ID)?.remove() + ensureWysiwygEditorStyles() +} diff --git a/src/advanced/wysiwyg/toolbar.ts b/src/advanced/wysiwyg/toolbar.ts new file mode 100644 index 0000000..fbf2e50 --- /dev/null +++ b/src/advanced/wysiwyg/toolbar.ts @@ -0,0 +1,165 @@ +import { DEFAULT_TOOLBAR } from '../wysiwygToolbar' +import type { WysiwygAiAction } from './ai/types' +import { WYSIWYG_HIGHLIGHT_COLORS, WYSIWYG_TEXT_COLORS } from './colors' +import type { ResolvedWysiwygFeatures } from './features' +import type { WysiwygTableAction } from './handlers' + +type Item = Record + +const button = (kind: string, icon: string, label: string, extra: Item = {}): Item => ({ + kind, + icon, + 'aria-label': label, + tooltip: { text: label }, + ...extra, +}) + +export const textColorDropdown = (): Item => ({ + icon: 'i-lucide-baseline', + 'aria-label': 'Couleur du texte', + tooltip: { text: 'Couleur du texte' }, + items: [ + [{ kind: 'textColor', label: 'Couleur par défaut', icon: 'i-lucide-circle-off' }], + WYSIWYG_TEXT_COLORS.map((c) => ({ + kind: 'textColor', + color: c.value, + label: c.label, + icon: 'i-lucide-circle', + ui: { itemLeadingIcon: c.swatch }, + })), + ], +}) + +export const highlightDropdown = (): Item => ({ + icon: 'i-lucide-highlighter', + 'aria-label': 'Surlignage', + tooltip: { text: 'Surlignage' }, + items: [ + [{ kind: 'highlight', label: 'Aucun surlignage', icon: 'i-lucide-circle-off' }], + WYSIWYG_HIGHLIGHT_COLORS.map((c) => ({ + kind: 'highlight', + color: c.value, + label: c.label, + icon: 'i-lucide-circle', + ui: { itemLeadingIcon: c.swatch }, + })), + ], +}) + +export const aiDropdown = (actions: WysiwygAiAction[], label = 'IA'): Item => ({ + icon: 'i-lucide-sparkles', + label, + 'aria-label': label, + tooltip: { text: label }, + items: actions.map((action) => ({ + kind: 'ai', + action: action.id, + label: action.label, + icon: action.icon ?? 'i-lucide-sparkles', + ...(action.description ? { description: action.description } : {}), + })), +}) + +type BuildToolbarOptions = { + features: ResolvedWysiwygFeatures + imagesEnabled: boolean + aiActions?: WysiwygAiAction[] + aiLabel?: string +} + +/** + * Default toolbar: `DEFAULT_TOOLBAR` adapted to the enabled features, plus color, + * table, tool (find / source / preview) and AI entries. + */ +export const buildWysiwygToolbar = ({ + features, + imagesEnabled, + aiActions = [], + aiLabel, +}: BuildToolbarOptions): unknown[][] => { + const groups: Item[][] = DEFAULT_TOOLBAR.map((group) => + (group as Item[]).filter((item) => { + if (item.kind === 'taskList') return features.taskList + if (item.kind === 'textAlign') return features.textAlign + if (item.kind === 'imageUpload') return imagesEnabled + return true + }), + ) + + const marksIndex = groups.findIndex((group) => group.some((item) => item.kind === 'mark')) + const colors: Item[] = [] + if (features.textColor) colors.push(textColorDropdown()) + if (features.highlight) colors.push(highlightDropdown()) + if (colors.length) groups.splice(marksIndex + 1, 0, colors) + + if (features.table) { + const insertGroup = groups.find((group) => group.some((item) => item.kind === 'link')) + insertGroup?.push(button('table', 'i-lucide-table', 'Insérer un tableau')) + } + + const tools: Item[] = [] + if (features.findReplace) { + tools.push(button('findReplace', 'i-lucide-search', 'Rechercher et remplacer')) + } + if (features.sourceMode) tools.push(button('sourceMode', 'i-lucide-code-xml', 'Code source')) + if (features.preview) tools.push(button('preview', 'i-lucide-eye', 'Aperçu')) + if (tools.length) groups.push(tools) + + if (aiActions.length) groups.push([aiDropdown(aiActions, aiLabel)]) + + return groups.filter((group) => group.length > 0) +} + +/** Text bubble (non-empty text selection). */ +export const buildWysiwygTextBubble = ({ + formatting, + features, + aiActions = [], + aiLabel, +}: { + formatting: boolean + features: ResolvedWysiwygFeatures + aiActions?: WysiwygAiAction[] + aiLabel?: string +}): unknown[][] => { + const groups: Item[][] = [] + if (aiActions.length) groups.push([aiDropdown(aiActions, aiLabel)]) + if (formatting) { + groups.push([ + button('mark', 'i-lucide-bold', 'Gras', { mark: 'bold' }), + button('mark', 'i-lucide-italic', 'Italique', { mark: 'italic' }), + button('mark', 'i-lucide-underline', 'Souligné', { mark: 'underline' }), + button('mark', 'i-lucide-strikethrough', 'Barré', { mark: 'strike' }), + button('mark', 'i-lucide-code', 'Code', { mark: 'code' }), + ]) + groups.push([button('link', 'i-lucide-link', 'Lien')]) + const colors: Item[] = [] + if (features.textColor) colors.push(textColorDropdown()) + if (features.highlight) colors.push(highlightDropdown()) + if (colors.length) groups.push(colors) + } + return groups +} + +const tableButton = (action: WysiwygTableAction, icon: string, label: string) => + button('tableAction', icon, label, { action }) + +/** Table bubble (cursor inside a table). */ +export const WYSIWYG_TABLE_BUBBLE: readonly unknown[][] = Object.freeze([ + [ + tableButton('addRowBefore', 'i-lucide-between-horizontal-end', 'Ligne au-dessus'), + tableButton('addRowAfter', 'i-lucide-between-horizontal-start', 'Ligne en dessous'), + tableButton('deleteRow', 'i-lucide-trash', 'Supprimer la ligne'), + ], + [ + tableButton('addColumnBefore', 'i-lucide-between-vertical-end', 'Colonne à gauche'), + tableButton('addColumnAfter', 'i-lucide-between-vertical-start', 'Colonne à droite'), + tableButton('deleteColumn', 'i-lucide-trash-2', 'Supprimer la colonne'), + ], + [ + tableButton('mergeOrSplit', 'i-lucide-table-cells-merge', 'Fusionner / scinder'), + tableButton('toggleHeaderRow', 'i-lucide-panel-top', 'Ligne d’en-tête'), + tableButton('toggleHeaderColumn', 'i-lucide-panel-left', 'Colonne d’en-tête'), + ], + [tableButton('deleteTable', 'i-lucide-grid-2x2-x', 'Supprimer le tableau')], +]) diff --git a/src/advanced/wysiwygOptions.ts b/src/advanced/wysiwygOptions.ts index 7c84853..ef00530 100644 --- a/src/advanced/wysiwygOptions.ts +++ b/src/advanced/wysiwygOptions.ts @@ -1,5 +1,16 @@ import type { AnyExtension } from '@tiptap/core' -import { DEFAULT_TOOLBAR } from './wysiwygToolbar' +import { + DISABLED_WYSIWYG_AI, + resolveWysiwygAiOptions, + type ResolvedWysiwygAiOptions, +} from './wysiwyg/ai/options' +import type { WysiwygAiOptions } from './wysiwyg/ai/types' +import { + resolveWysiwygFeatures, + type ResolvedWysiwygFeatures, + type WysiwygFeatures, +} from './wysiwyg/features' +import { buildWysiwygToolbar } from './wysiwyg/toolbar' export type WysiwygContentType = 'html' | 'json' @@ -48,7 +59,7 @@ export type WysiwygOptions = { debounce?: number /** * Toolbar items (Nuxt UI `EditorToolbarItem[][]`), or `false` to hide. - * Default: built-in French toolbar. + * Default: built-in French toolbar, adapted to `features` and `ai`. */ toolbar?: false | unknown[][] /** Spacing preset for the contenteditable. Default: `compact`. */ @@ -80,6 +91,14 @@ export type WysiwygOptions = { /** Extra / overriding Nuxt UI editor handlers. */ handlers?: WysiwygHandlers hideRequiredAsterisk?: boolean + /** Editor capabilities (tables, colors, find & replace…). `false` disables them all. */ + features?: false | WysiwygFeatures + /** Formatting bubble on text selection. Default: `false`. */ + bubbleMenu?: boolean + /** `/` command palette. Default: `false`. */ + slashCommands?: boolean + /** AI writing assistant, inert unless `ai.enabled === true`. */ + ai?: WysiwygAiOptions } export type ResolvedWysiwygOptions = { @@ -105,6 +124,10 @@ export type ResolvedWysiwygOptions = { } extensions: AnyExtension[] handlers?: WysiwygHandlers + features: ResolvedWysiwygFeatures + bubbleMenu: boolean + slashCommands: boolean + ai: ResolvedWysiwygAiOptions } const DENSITY_PRESETS: Record< @@ -178,8 +201,20 @@ export const resolveWysiwygOptions = ( ? opts.imageMaxSize : 2 * 1024 * 1024 + const features = resolveWysiwygFeatures(opts.features) + const ai = opts.ai === undefined ? DISABLED_WYSIWYG_AI : resolveWysiwygAiOptions(opts.ai) + const toolbar = - opts.toolbar === false ? false : Array.isArray(opts.toolbar) ? opts.toolbar : DEFAULT_TOOLBAR + opts.toolbar === false + ? false + : Array.isArray(opts.toolbar) + ? opts.toolbar + : buildWysiwygToolbar({ + features, + imagesEnabled, + aiActions: ai.actions, + aiLabel: ai.labels.menu, + }) return { contentType, @@ -207,5 +242,9 @@ export const resolveWysiwygOptions = ( }, extensions: Array.isArray(opts.extensions) ? opts.extensions : [], handlers: opts.handlers, + features, + bubbleMenu: opts.bubbleMenu === true, + slashCommands: opts.slashCommands === true, + ai, } } diff --git a/tests/advanced/wysiwygAi.test.ts b/tests/advanced/wysiwygAi.test.ts new file mode 100644 index 0000000..b56083b --- /dev/null +++ b/tests/advanced/wysiwygAi.test.ts @@ -0,0 +1,366 @@ +// @vitest-environment jsdom + +import type { Editor } from '@tiptap/core' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { DEFAULT_WYSIWYG_AI_ACTIONS } from '../../src/advanced/wysiwyg/ai/actions' +import { + buildWysiwygAiRequest, + resolveWysiwygAiTarget, +} from '../../src/advanced/wysiwyg/ai/context' +import { resolveWysiwygAiOptions } from '../../src/advanced/wysiwyg/ai/options' +import type { + WysiwygAiAction, + WysiwygAiRequest, + WysiwygAiStreamChunk, + WysiwygAiTransport, +} from '../../src/advanced/wysiwyg/ai/types' +import { useWysiwygAi } from '../../src/advanced/wysiwyg/ai/useWysiwygAi' +import { createTestEditor, posOf, selectText } from './wysiwygEditorHarness' + +const action = (id: string): WysiwygAiAction => { + const found = DEFAULT_WYSIWYG_AI_ACTIONS.find((a) => a.id === id) + if (!found) throw new Error(id) + return found +} + +/** Stream whose chunks are released one by one by the test. */ +const controllableStream = () => { + const queue: Array = [] + let wake: (() => void) | null = null + const push = (item: WysiwygAiStreamChunk | Error | null) => { + queue.push(item) + wake?.() + } + async function* iterate(signal?: AbortSignal): AsyncGenerator { + while (true) { + if (!queue.length) await new Promise((resolve) => (wake = resolve)) + wake = null + if (signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' }) + const item = queue.shift() + if (item === null || item === undefined) return + if (item instanceof Error) throw item + yield item + } + } + return { push, iterate } +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +let editor: Editor + +beforeEach(() => { + editor = createTestEditor('

Hello brave world.

Second paragraph.

') +}) + +afterEach(() => { + editor.destroy() + document.body.innerHTML = '' +}) + +const setup = (transport: WysiwygAiTransport, extra: Record = {}) => { + const options = ref(resolveWysiwygAiOptions({ enabled: true, transport, ...extra })) + return useWysiwygAi({ getEditor: () => editor, options }) +} + +describe('AI request context', () => { + it('targets the selected text and bounds document context', () => { + selectText(editor, 'brave') + const target = resolveWysiwygAiTarget(editor, action('improve')) + const request = buildWysiwygAiRequest(editor, action('improve'), target, { + maxContextCharacters: 10, + }) + + expect(target).toMatchObject({ mode: 'transform', kind: 'selection', text: 'brave' }) + expect(request).toMatchObject({ + action: 'improve', + mode: 'transform', + selectedText: 'brave', + selection: { from: target.from, to: target.to }, + instruction: 'Improve the selected text while preserving its meaning.', + currentBlock: 'Hello brav', + textBeforeSelection: 'ello ', + textAfterSelection: ' worl', + documentText: 'Hello brav', + truncated: true, + format: 'text', + }) + expect(request.html).toBeUndefined() + expect(request.json).toBeUndefined() + }) + + it('falls back to the current block, then to the cursor, when nothing is selected', () => { + editor.commands.setTextSelection(posOf(editor, 'world')) + const block = resolveWysiwygAiTarget(editor, action('shorter')) + expect(block).toMatchObject({ kind: 'block', mode: 'transform', text: 'Hello brave world.' }) + + const generate = resolveWysiwygAiTarget(editor, action('continue')) + expect(generate).toMatchObject({ kind: 'cursor', mode: 'generate', text: '' }) + + const auto = resolveWysiwygAiTarget(editor, action('ask')) + expect(auto.mode).toBe('generate') + + editor.commands.setContent('

') + expect(resolveWysiwygAiTarget(editor, action('improve')).kind).toBe('cursor') + }) + + it('appends the user instruction and attaches HTML / JSON / metadata only on request', () => { + selectText(editor, 'world') + const target = resolveWysiwygAiTarget(editor, action('translate')) + const request = buildWysiwygAiRequest(editor, action('translate'), target, { + instruction: ' Spanish ', + includeHtml: true, + includeJson: true, + format: 'html', + metadata: { locale: 'fr' }, + }) + expect(request.instruction).toMatch(/\n\nSpanish$/) + expect(request.html).toContain('

Hello brave world.

') + expect(request.json?.type).toBe('doc') + expect(request.metadata).toEqual({ locale: 'fr' }) + expect(request.format).toBe('html') + expect(request.truncated).toBeUndefined() + }) +}) + +describe('useWysiwygAi', () => { + it('streams into the preview without touching the document until accepted', async () => { + const stream = controllableStream() + const requests: WysiwygAiRequest[] = [] + const transport: WysiwygAiTransport = { + generate: vi.fn(), + stream: (request, options) => { + requests.push(request) + return stream.iterate(options?.signal) + }, + } + const ai = setup(transport) + const before = editor.getHTML() + selectText(editor, 'brave') + + const done = ai.start(action('improve')) + expect(ai.status.value).toBe('generating') + expect(ai.isGenerating.value).toBe(true) + + stream.push({ type: 'text-delta', delta: 'cour' }) + stream.push('ageous') + await flush() + expect(ai.result.value).toBe('courageous') + expect(editor.getHTML()).toBe(before) + + stream.push(null) + await done + expect(ai.status.value).toBe('done') + expect(requests[0]).toMatchObject({ action: 'improve', selectedText: 'brave' }) + expect(transport.generate).not.toHaveBeenCalled() + expect(editor.getHTML()).toBe(before) + + expect(ai.accept('replace')).toBe(true) + expect(editor.getHTML()).toBe('

Hello courageous world.

Second paragraph.

') + expect(ai.status.value).toBe('idle') + }) + + it('aborts the transport signal on cancel and keeps the partial result', async () => { + const stream = controllableStream() + let signal: AbortSignal | undefined + const ai = setup({ + generate: vi.fn(), + stream: (_request, options) => { + signal = options?.signal + return stream.iterate(options?.signal) + }, + }) + selectText(editor, 'brave') + const done = ai.start(action('improve')) + stream.push('partial') + await flush() + + ai.cancel() + expect(signal?.aborted).toBe(true) + expect(ai.status.value).toBe('stopped') + stream.push(' ignored') + await done + expect(ai.result.value).toBe('partial') + expect(ai.accept('insertBelow')).toBe(true) + expect(editor.getHTML()).toBe('

Hello brave world.

partial

Second paragraph.

') + }) + + it('reports transport errors and retries the same request', async () => { + const generate = vi + .fn() + .mockRejectedValueOnce(new Error('Backend unavailable')) + .mockResolvedValueOnce({ text: 'Fixed.' }) + const ai = setup({ generate }) + selectText(editor, 'brave') + + await ai.start(action('fixGrammar')) + expect(ai.status.value).toBe('error') + expect(ai.error.value).toBe('Backend unavailable') + expect(ai.accept('replace')).toBe(false) + + await ai.retry() + expect(generate).toHaveBeenCalledTimes(2) + expect(generate.mock.calls[1]?.[0]).toBe(generate.mock.calls[0]?.[0]) + expect(ai.status.value).toBe('done') + expect(ai.result.value).toBe('Fixed.') + expect(ai.error.value).toBeNull() + }) + + it('turns stream error chunks into an error state', async () => { + const ai = setup({ + generate: vi.fn(), + async *stream() { + yield 'half' + yield { type: 'error', errorText: 'Rate limited' } + }, + }) + selectText(editor, 'brave') + await ai.start(action('improve')) + expect(ai.status.value).toBe('error') + expect(ai.error.value).toBe('Rate limited') + }) + + it('uses generate() when streaming is disabled and inserts at the cursor', async () => { + const generate = vi.fn().mockResolvedValue({ text: ' Continued text' }) + const stream = vi.fn() + const ai = setup({ generate, stream }, { stream: false }) + editor.commands.setTextSelection(posOf(editor, '.') + 1) + + await ai.start(action('continue')) + expect(stream).not.toHaveBeenCalled() + expect(ai.target.value?.kind).toBe('cursor') + expect(ai.accept('insert')).toBe(true) + expect(editor.getText()).toContain('Hello brave world. Continued text') + }) + + it('asks for a custom instruction before running prompt actions', async () => { + const generate = vi.fn().mockResolvedValue({ text: 'Bonjour' }) + const ai = setup({ generate }) + selectText(editor, 'Hello') + + ai.open(action('ask')) + expect(ai.status.value).toBe('prompting') + expect(generate).not.toHaveBeenCalled() + + await ai.start(action('ask'), 'Translate to French') + expect(generate.mock.calls[0]?.[0]).toMatchObject({ + action: 'ask', + mode: 'transform', + instruction: 'Translate to French', + selectedText: 'Hello', + }) + }) + + it('replaces the whole current block with multi-paragraph output', async () => { + const ai = setup({ generate: vi.fn().mockResolvedValue({ text: 'One.\n\nTwo.' }) }) + editor.commands.setTextSelection(posOf(editor, 'brave')) + await ai.start(action('longer')) + expect(ai.target.value?.kind).toBe('block') + expect(ai.accept('replace')).toBe(true) + expect(editor.getHTML()).toBe('

One.

Two.

Second paragraph.

') + }) + + it('never interprets plain-text output as markup', async () => { + const ai = setup({ + generate: vi.fn().mockResolvedValue({ text: '' }), + }) + selectText(editor, 'brave') + await ai.start(action('rewrite')) + ai.accept('replace') + expect(editor.getHTML()).toContain('<img src=x onerror=alert(1)>') + expect(editor.view.dom.querySelector('img')).toBeNull() + }) + + it('sanitizes HTML output before parsing it with the editor schema', async () => { + const ai = setup( + { + generate: vi.fn().mockResolvedValue({ + text: 'boldx', + }), + }, + { format: 'html' }, + ) + selectText(editor, 'brave') + await ai.start(action('rewrite')) + ai.accept('replace') + const html = editor.getHTML() + expect(html).toContain('bold') + expect(html).not.toContain('script') + expect(html).not.toContain('javascript:') + }) + + it('refuses to replace text that changed during generation', async () => { + const ai = setup({ generate: vi.fn().mockResolvedValue({ text: 'bold' }) }) + selectText(editor, 'brave') + await ai.start(action('rewrite')) + + const from = posOf(editor, 'brave') + editor.commands.insertContentAt({ from, to: from + 1 }, 'c') + expect(ai.accept('replace')).toBe(false) + expect(ai.error.value).toMatch(/modifié/) + expect(editor.getText()).toContain('crave') + }) + + it('maps the target through unrelated edits made while generating', async () => { + const ai = setup({ generate: vi.fn().mockResolvedValue({ text: 'bold' }) }) + selectText(editor, 'brave') + await ai.start(action('rewrite')) + + editor.commands.insertContentAt(1, 'Oh, ') + expect(ai.accept('replace')).toBe(true) + expect(editor.getText()).toContain('Oh, Hello bold world.') + }) + + it('applies structured operations only on explicit acceptance', async () => { + const ai = setup({ + generate: vi.fn().mockResolvedValue({ + text: '', + operations: [{ type: 'replaceText', from: 1, to: 6, content: 'Hi' }], + explanation: 'Shorter greeting.', + }), + }) + editor.commands.setTextSelection(1) + await ai.start(action('shorter')) + expect(editor.getText()).toContain('Hello brave') + expect(ai.explanation.value).toBe('Shorter greeting.') + expect(ai.accept('operations')).toBe(true) + expect(editor.getText()).toContain('Hi brave world.') + }) + + it('does nothing when the editor is read-only or disabled', async () => { + const generate = vi.fn().mockResolvedValue({ text: 'x' }) + const ai = setup({ generate }) + selectText(editor, 'brave') + editor.setEditable(false) + + ai.open(action('improve')) + await ai.start(action('improve')) + expect(generate).not.toHaveBeenCalled() + expect(ai.status.value).toBe('idle') + + editor.setEditable(true) + await ai.start(action('improve')) + editor.setEditable(false) + const before = editor.getHTML() + expect(ai.accept('replace')).toBe(false) + expect(editor.getHTML()).toBe(before) + }) + + it('discards results and ignores late chunks', async () => { + const stream = controllableStream() + const ai = setup({ + generate: vi.fn(), + stream: (_request, options) => stream.iterate(options?.signal), + }) + selectText(editor, 'brave') + const done = ai.start(action('improve')) + ai.discard() + stream.push('late') + stream.push(null) + await done + expect(ai.status.value).toBe('idle') + expect(ai.result.value).toBe('') + expect(ai.target.value).toBeNull() + }) +}) diff --git a/tests/advanced/wysiwygAiOptions.test.ts b/tests/advanced/wysiwygAiOptions.test.ts new file mode 100644 index 0000000..8c1226d --- /dev/null +++ b/tests/advanced/wysiwygAiOptions.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DEFAULT_WYSIWYG_AI_ACTIONS, + DEFAULT_WYSIWYG_AI_ACTION_IDS, + DEFAULT_WYSIWYG_AI_LABELS, + resolveWysiwygAiActions, + selectWysiwygAiSlashActions, +} from '../../src/advanced/wysiwyg/ai/actions' +import { DISABLED_WYSIWYG_AI, resolveWysiwygAiOptions } from '../../src/advanced/wysiwyg/ai/options' +import { resolveWysiwygOptions } from '../../src/advanced/wysiwygOptions' + +const transport = { generate: vi.fn() } + +describe('resolveWysiwygAiActions', () => { + it('defaults to every built-in action, in order', () => { + expect(resolveWysiwygAiActions(undefined).map((a) => a.id)).toEqual([ + ...DEFAULT_WYSIWYG_AI_ACTION_IDS, + ]) + }) + + it('maps ids, overrides defaults and adds custom actions', () => { + const actions = resolveWysiwygAiActions([ + 'improve', + 'unknown', + { id: 'shorter', label: 'Plus court' }, + { id: 'professional', label: 'Professional tone', instruction: 'Rewrite formally.' }, + { id: 'legal', label: '', instruction: 'Use legal wording.' }, + { label: 'missing id' }, + 42, + ]) + expect(actions.map((a) => a.id)).toEqual(['improve', 'shorter', 'professional', 'legal']) + expect(actions[1]).toMatchObject({ + label: 'Plus court', + instruction: 'Rewrite the text more concisely.', + }) + expect(actions[2]?.instruction).toBe('Rewrite formally.') + expect(actions[3]?.label).toBe('legal') + }) + + it('keeps default slash actions plus every custom one', () => { + const actions = resolveWysiwygAiActions([ + 'improve', + 'fixGrammar', + 'ask', + { id: 'custom', label: 'C' }, + ]) + expect(selectWysiwygAiSlashActions(actions).map((a) => a.id)).toEqual([ + 'improve', + 'ask', + 'custom', + ]) + }) + + it('ships provider-agnostic defaults', () => { + for (const action of DEFAULT_WYSIWYG_AI_ACTIONS) { + expect(action.instruction ?? '').not.toMatch(/openai|gpt|claude|anthropic|gemini/i) + } + expect(Object.isFrozen(DEFAULT_WYSIWYG_AI_ACTIONS)).toBe(true) + }) +}) + +describe('resolveWysiwygAiOptions', () => { + it('stays inert unless enabled with a transport or endpoint', () => { + expect(resolveWysiwygAiOptions(undefined)).toBe(DISABLED_WYSIWYG_AI) + expect(resolveWysiwygAiOptions({ endpoint: '/ai' })).toBe(DISABLED_WYSIWYG_AI) + expect(resolveWysiwygAiOptions({ enabled: 'true', endpoint: '/ai' })).toBe(DISABLED_WYSIWYG_AI) + expect(resolveWysiwygAiOptions({ enabled: true })).toBe(DISABLED_WYSIWYG_AI) + expect(resolveWysiwygAiOptions({ enabled: true, transport: {} })).toBe(DISABLED_WYSIWYG_AI) + }) + + it('creates the HTTP transport from an endpoint without calling it', () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + try { + const resolved = resolveWysiwygAiOptions({ enabled: true, endpoint: '/api/ai/editor' }) + expect(resolved.enabled).toBe(true) + expect(typeof resolved.transport?.stream).toBe('function') + expect(fetch).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + it('prefers a custom transport and applies defaults', () => { + const resolved = resolveWysiwygAiOptions({ enabled: true, endpoint: '/x', transport }) + expect(resolved).toMatchObject({ + enabled: true, + transport, + bubbleMenu: true, + slashCommands: true, + stream: true, + maxContextCharacters: 12000, + includeHtml: false, + includeJson: false, + format: 'text', + labels: DEFAULT_WYSIWYG_AI_LABELS, + }) + expect(resolved.metadata).toBeUndefined() + }) + + it('honours explicit settings', () => { + const resolved = resolveWysiwygAiOptions({ + enabled: true, + transport, + actions: ['summarize'], + bubbleMenu: false, + slashCommands: false, + stream: false, + maxContextCharacters: 500, + includeHtml: true, + includeJson: true, + format: 'html', + body: { locale: 'fr' }, + labels: { menu: 'Assistant' }, + }) + expect(resolved).toMatchObject({ + bubbleMenu: false, + slashCommands: false, + stream: false, + maxContextCharacters: 500, + includeHtml: true, + includeJson: true, + format: 'html', + metadata: { locale: 'fr' }, + }) + expect(resolved.actions.map((a) => a.id)).toEqual(['summarize']) + expect(resolved.labels.menu).toBe('Assistant') + expect(resolved.labels.stop).toBe(DEFAULT_WYSIWYG_AI_LABELS.stop) + expect( + resolveWysiwygAiOptions({ enabled: true, transport, maxContextCharacters: -1 }) + .maxContextCharacters, + ).toBe(12000) + }) +}) + +describe('resolveWysiwygOptions with AI and features', () => { + it('keeps `{ wysiwyg: true }` valid with AI off and menus opt-in', () => { + const resolved = resolveWysiwygOptions({ wysiwyg: true }, 'string') + expect(resolved.ai.enabled).toBe(false) + expect(resolved.bubbleMenu).toBe(false) + expect(resolved.slashCommands).toBe(false) + expect(resolved.features.table).toBe(true) + const toolbar = resolved.toolbar as Array>> + expect(JSON.stringify(toolbar)).not.toContain('"kind":"ai"') + }) + + it('adds the AI dropdown to the default toolbar when enabled', () => { + const resolved = resolveWysiwygOptions( + { ai: { enabled: true, transport, actions: ['improve'] } }, + 'object', + ) + const last = (resolved.toolbar as Array>>).at(-1) + expect(last?.[0]).toMatchObject({ + icon: 'i-lucide-sparkles', + items: [{ kind: 'ai', action: 'improve' }], + }) + }) + + it('never alters a custom toolbar', () => { + const toolbar = [[{ kind: 'undo' }]] + expect( + resolveWysiwygOptions({ toolbar, ai: { enabled: true, transport } }, 'object').toolbar, + ).toBe(toolbar) + }) + + it('reads menu flags strictly', () => { + expect( + resolveWysiwygOptions({ bubbleMenu: true, slashCommands: 'yes' }, 'object'), + ).toMatchObject({ bubbleMenu: true, slashCommands: false }) + }) +}) diff --git a/tests/advanced/wysiwygAiTransport.test.ts b/tests/advanced/wysiwygAiTransport.test.ts new file mode 100644 index 0000000..542d681 --- /dev/null +++ b/tests/advanced/wysiwygAiTransport.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, vi } from 'vitest' +import { + WysiwygAiTransportError, + createWysiwygAiHttpTransport, +} from '../../src/advanced/wysiwyg/ai/httpTransport' +import { + collectWysiwygAiStream, + parseWysiwygAiJsonResponse, + uiMessagePartToChunk, +} from '../../src/advanced/wysiwyg/ai/stream' +import type { WysiwygAiRequest, WysiwygAiStreamChunk } from '../../src/advanced/wysiwyg/ai/types' + +const request: WysiwygAiRequest = { + action: 'improve', + mode: 'transform', + selectedText: 'helo', + documentText: 'helo world', + format: 'text', +} + +/** Body split at arbitrary byte boundaries, as a network would deliver it. */ +const streamOf = (...parts: string[]) => { + const encoder = new TextEncoder() + return new ReadableStream({ + start(controller) { + for (const part of parts) controller.enqueue(encoder.encode(part)) + controller.close() + }, + }) +} + +const collect = async (chunks: AsyncIterable) => { + const out: WysiwygAiStreamChunk[] = [] + for await (const chunk of chunks) out.push(chunk) + return out +} + +const sse = (...events: unknown[]) => + events.map((event) => `data: ${typeof event === 'string' ? event : JSON.stringify(event)}\n\n`) + +describe('createWysiwygAiHttpTransport', () => { + it('posts the editor request as JSON with stream flag, headers and abort signal', async () => { + const fetch = vi.fn( + async () => new Response('Hello', { headers: { 'content-type': 'text/plain' } }), + ) + const controller = new AbortController() + const transport = createWysiwygAiHttpTransport({ + endpoint: '/api/ai/editor', + headers: async () => ({ 'x-csrf-token': 'token' }), + fetch: fetch as unknown as typeof globalThis.fetch, + }) + + await collect(transport.stream!(request, { signal: controller.signal })) + + const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('/api/ai/editor') + expect(init.method).toBe('POST') + expect(init.signal).toBe(controller.signal) + expect(init.credentials).toBe('same-origin') + expect(init.headers).toMatchObject({ + 'content-type': 'application/json', + 'x-csrf-token': 'token', + }) + expect(JSON.parse(String(init.body))).toEqual({ ...request, stream: true }) + }) + + it('reads AI SDK text streams (toTextStreamResponse)', async () => { + const transport = createWysiwygAiHttpTransport({ + endpoint: '/ai', + fetch: (async () => + new Response(streamOf('Hel', 'lo ', 'wörld'), { + headers: { 'content-type': 'text/plain; charset=utf-8' }, + })) as typeof fetch, + }) + const chunks = await collect(transport.stream!(request)) + expect( + chunks.map((c) => (typeof c === 'string' ? c : 'delta' in c ? c.delta : '')).join(''), + ).toBe('Hello wörld') + }) + + it('reads AI SDK UI message streams split across network chunks', async () => { + const body = [ + ...sse( + { type: 'start' }, + { type: 'text-start', id: 't' }, + { type: 'text-delta', id: 't', delta: 'Hello' }, + { type: 'text-delta', id: 't', delta: ', world' }, + { type: 'data-operations', data: { operations: [{ type: 'insertBelow', content: 'x' }] } }, + { type: 'text-end', id: 't' }, + { type: 'finish' }, + '[DONE]', + ), + ].join('') + const middle = Math.floor(body.length / 2) + const transport = createWysiwygAiHttpTransport({ + endpoint: '/ai', + fetch: (async () => + new Response(streamOf(body.slice(0, 7), body.slice(7, middle), body.slice(middle)), { + headers: { 'content-type': 'text/event-stream', 'x-vercel-ai-ui-message-stream': 'v1' }, + })) as typeof fetch, + }) + + expect(await collect(transport.stream!(request))).toEqual([ + { type: 'text-delta', delta: 'Hello' }, + { type: 'text-delta', delta: ', world' }, + { type: 'operations', operations: [{ type: 'insertBelow', content: 'x' }] }, + ]) + }) + + it('surfaces UI message stream errors', async () => { + const transport = createWysiwygAiHttpTransport({ + endpoint: '/ai', + fetch: (async () => + new Response(streamOf(...sse({ type: 'error', errorText: 'Quota exceeded' })), { + headers: { 'content-type': 'text/event-stream' }, + })) as typeof fetch, + }) + await expect(transport.generate(request)).rejects.toThrow('Quota exceeded') + }) + + it('parses JSON responses for generate() and stream()', async () => { + const json = () => + new Response( + JSON.stringify({ operation: 'replaceSelection', content: 'hello', explanation: 'Typo.' }), + { headers: { 'content-type': 'application/json' } }, + ) + const transport = createWysiwygAiHttpTransport({ + endpoint: '/ai', + fetch: (async () => json()) as typeof fetch, + }) + + expect(await transport.generate(request)).toEqual({ + text: 'hello', + operations: [{ type: 'replaceSelection', content: 'hello' }], + explanation: 'Typo.', + }) + expect(await collectWysiwygAiStream(transport.stream!(request))).toEqual({ + text: 'hello', + operations: [{ type: 'replaceSelection', content: 'hello' }], + explanation: 'Typo.', + }) + }) + + it('collects streamed bodies for generate()', async () => { + const transport = createWysiwygAiHttpTransport({ + endpoint: '/ai', + fetch: (async () => new Response(streamOf('a', 'b'))) as typeof fetch, + }) + expect(await transport.generate(request)).toEqual({ text: 'ab' }) + }) + + it('throws a typed error with the backend message on HTTP failures', async () => { + const transport = createWysiwygAiHttpTransport({ + endpoint: '/ai', + fetch: (async () => + new Response(JSON.stringify({ statusMessage: 'Unauthorized' }), { + status: 401, + statusText: 'Unauthorized', + })) as typeof fetch, + }) + const error = await transport.generate(request).catch((err: unknown) => err) + expect(error).toBeInstanceOf(WysiwygAiTransportError) + expect(error).toMatchObject({ status: 401 }) + expect((error as Error).message).toBe('AI request failed (401 Unauthorized): Unauthorized') + }) + + it('propagates aborts from fetch', async () => { + const controller = new AbortController() + const transport = createWysiwygAiHttpTransport({ + endpoint: '/ai', + fetch: ((_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ) + })) as unknown as typeof fetch, + }) + const pending = transport.generate(request, { signal: controller.signal }) + controller.abort() + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('fails clearly without fetch', async () => { + const original = globalThis.fetch + vi.stubGlobal('fetch', undefined) + try { + const transport = createWysiwygAiHttpTransport({ endpoint: '/ai' }) + await expect(transport.generate(request)).rejects.toThrow('fetch is not available') + } finally { + vi.stubGlobal('fetch', original) + vi.unstubAllGlobals() + } + }) +}) + +describe('AI response normalization', () => { + it('normalizes JSON shapes and drops malformed operations', () => { + expect(parseWysiwygAiJsonResponse('plain')).toEqual({ text: 'plain' }) + expect(parseWysiwygAiJsonResponse(null)).toEqual({ text: '' }) + expect( + parseWysiwygAiJsonResponse({ + content: 'c', + operations: [ + { type: 'replaceText', from: 2, to: 1, content: 'bad range' }, + { type: 'insertContent', at: -1, content: 'bad pos' }, + { type: 'deleteEverything', content: '' }, + { type: 'insertContent', at: 3, content: 'ok' }, + 'junk', + ], + }), + ).toEqual({ text: 'c', operations: [{ type: 'insertContent', at: 3, content: 'ok' }] }) + }) + + it('ignores UI message parts the editor does not use', () => { + expect(uiMessagePartToChunk({ type: 'reasoning-delta', delta: 'thinking' })).toBeUndefined() + expect(uiMessagePartToChunk({ type: 'text-delta', delta: '' })).toBeUndefined() + expect(uiMessagePartToChunk({ type: 'text-delta', textDelta: 'legacy' })).toEqual({ + type: 'text-delta', + delta: 'legacy', + }) + expect(uiMessagePartToChunk({ type: 'error' })).toEqual({ + type: 'error', + errorText: 'Stream error', + }) + expect(uiMessagePartToChunk({ type: 'data-other', data: { foo: 1 } })).toBeUndefined() + expect(uiMessagePartToChunk('nope')).toBeUndefined() + }) +}) diff --git a/tests/advanced/wysiwygEditorHarness.ts b/tests/advanced/wysiwygEditorHarness.ts new file mode 100644 index 0000000..b30dbde --- /dev/null +++ b/tests/advanced/wysiwygEditorHarness.ts @@ -0,0 +1,29 @@ +import { Editor, type AnyExtension } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' + +/** Real Tiptap editor mounted in jsdom (callers must opt into `@vitest-environment jsdom`). */ +export const createTestEditor = (content: string, extensions: AnyExtension[] = []) => { + const element = document.createElement('div') + document.body.appendChild(element) + return new Editor({ element, extensions: [StarterKit, ...extensions], content }) +} + +/** Document position of the first occurrence of `text` (start of the match). */ +export const posOf = (editor: Editor, text: string): number => { + let found = -1 + editor.state.doc.descendants((node, pos) => { + if (found >= 0) return false + if (node.isText && node.text) { + const index = node.text.indexOf(text) + if (index >= 0) found = pos + index + } + return true + }) + if (found < 0) throw new Error(`"${text}" not found`) + return found +} + +export const selectText = (editor: Editor, text: string) => { + const from = posOf(editor, text) + editor.commands.setTextSelection({ from, to: from + text.length }) +} diff --git a/tests/advanced/wysiwygFeatures.test.ts b/tests/advanced/wysiwygFeatures.test.ts new file mode 100644 index 0000000..8170483 --- /dev/null +++ b/tests/advanced/wysiwygFeatures.test.ts @@ -0,0 +1,432 @@ +// @vitest-environment jsdom + +import { Extension, type Editor } from '@tiptap/core' +import { TableKit } from '@tiptap/extension-table' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computed, reactive, ref } from 'vue' +import { isSafeWysiwygUrl, sanitizeWysiwygAiHtml } from '../../src/advanced/wysiwyg/ai/sanitize' +import { textToWysiwygParagraphs } from '../../src/advanced/wysiwyg/ai/operations' +import { createWysiwygEditorTools } from '../../src/advanced/wysiwyg/ai/tools' +import { buildWysiwygFeatureExtensions } from '../../src/advanced/wysiwyg/extensions' +import { + DEFAULT_WYSIWYG_FEATURES, + resolveWysiwygFeatures, +} from '../../src/advanced/wysiwyg/features' +import { createWysiwygHandlers, type WysiwygUiState } from '../../src/advanced/wysiwyg/handlers' +import { findWysiwygTextRanges, getWysiwygSearchState } from '../../src/advanced/wysiwyg/search' +import { buildWysiwygSlashItems } from '../../src/advanced/wysiwyg/slash/commands' +import { parseWysiwygSource, serializeWysiwygSource } from '../../src/advanced/wysiwyg/source' +import { + WYSIWYG_TABLE_BUBBLE, + buildWysiwygTextBubble, + buildWysiwygToolbar, +} from '../../src/advanced/wysiwyg/toolbar' +import { DEFAULT_TOOLBAR } from '../../src/advanced/wysiwygToolbar' +import { createTestEditor, posOf, selectText } from './wysiwygEditorHarness' + +const editors: Editor[] = [] +const editorWith = (content: string, features = DEFAULT_WYSIWYG_FEATURES) => { + const editor = createTestEditor(content, buildWysiwygFeatureExtensions(features)) + editors.push(editor) + return editor +} + +afterEach(() => { + for (const editor of editors.splice(0)) editor.destroy() + document.body.innerHTML = '' +}) + +const kinds = (groups: unknown[][]) => + groups.flat().map((item) => (item as Record).kind ?? 'dropdown') + +describe('features', () => { + it('enables everything by default and supports per-feature or global opt-out', () => { + expect(resolveWysiwygFeatures(undefined)).toEqual(DEFAULT_WYSIWYG_FEATURES) + expect(resolveWysiwygFeatures({ table: false, preview: 'no' })).toMatchObject({ + table: false, + preview: true, + }) + expect(Object.values(resolveWysiwygFeatures(false)).every((v) => v === false)).toBe(true) + }) + + it('registers feature extensions and lets host extensions win on name clashes', () => { + const all = buildWysiwygFeatureExtensions(DEFAULT_WYSIWYG_FEATURES).map((e) => e.name) + expect(all).toEqual([ + 'taskList', + 'taskItem', + 'textAlign', + 'textStyle', + 'color', + 'highlight', + 'tableKit', + 'wysiwygSearch', + ]) + + const hostTable = TableKit.configure({}) + const hostAlign = Extension.create({ name: 'textAlign' }) + const deduped = buildWysiwygFeatureExtensions(DEFAULT_WYSIWYG_FEATURES, [hostTable, hostAlign]) + expect(deduped.map((e) => e.name)).not.toContain('tableKit') + expect(deduped.map((e) => e.name)).not.toContain('textAlign') + + expect(buildWysiwygFeatureExtensions(resolveWysiwygFeatures(false))).toEqual([]) + expect( + buildWysiwygFeatureExtensions({ ...resolveWysiwygFeatures(false), highlight: true }).map( + (e) => e.name, + ), + ).toEqual(['textStyle', 'highlight']) + }) + + it('parses and renders tables, task lists, colors and highlights', () => { + const html = + '

A

1

' + + '
  • Done

' + + '

red hl

' + const editor = editorWith(html) + const out = editor.getHTML() + expect(out).toContain(' { + it('finds matches across marks, case-insensitively by default', () => { + const editor = editorWith('

Foo foo FOO

foo

') + expect(findWysiwygTextRanges(editor.state.doc, '')).toEqual([]) + expect(findWysiwygTextRanges(editor.state.doc, 'foo')).toHaveLength(4) + expect(findWysiwygTextRanges(editor.state.doc, 'foo', true)).toHaveLength(2) + }) + + it('highlights, navigates and replaces through commands', () => { + const editor = editorWith('

cat and cat and cat

') + editor.commands.setSearchTerm('cat') + let state = getWysiwygSearchState(editor.state) + expect(state).toMatchObject({ term: 'cat', current: 0 }) + expect(state.results).toHaveLength(3) + expect(editor.view.dom.querySelectorAll('.jf-wysiwyg-search-match')).toHaveLength(3) + + editor.commands.nextSearchResult() + editor.commands.nextSearchResult() + editor.commands.nextSearchResult() + expect(getWysiwygSearchState(editor.state).current).toBe(0) + editor.commands.previousSearchResult() + expect(getWysiwygSearchState(editor.state).current).toBe(2) + + editor.commands.replaceSearchResult('dog') + expect(editor.getText()).toBe('cat and cat and dog') + state = getWysiwygSearchState(editor.state) + expect(state.results).toHaveLength(2) + + editor.commands.replaceAllSearchResults('') + expect(editor.getText()).toBe(' and and dog') + expect(editor.commands.replaceAllSearchResults('x')).toBe(false) + expect(editor.commands.nextSearchResult()).toBe(false) + + editor.commands.setSearchCaseSensitive(true) + editor.commands.setSearchTerm('') + expect(editor.view.dom.querySelectorAll('.jf-wysiwyg-search-match')).toHaveLength(0) + }) +}) + +describe('handlers', () => { + const setup = (content: string, ai: Parameters[0]['ai'] = null) => { + const editor = editorWith(content) + const ui = reactive({ panel: null, mode: 'edit' }) + const handlers = createWysiwygHandlers({ + ui, + linkEditor: true, + ai, + aiActions: [{ id: 'improve', label: 'Improve' }], + }) + return { editor, ui, handlers } + } + + it('applies colors and highlights to the selection', () => { + const { editor, handlers } = setup('

paint me

') + selectText(editor, 'paint') + const red = { color: '#dc2626' } + expect(handlers.textColor!.isDisabled!(editor)).toBe(false) + expect(handlers.textColor!.canExecute(editor, red)).toBe(true) + handlers.textColor!.execute(editor, red).run() + expect(handlers.textColor!.isActive(editor, red)).toBe(true) + expect(editor.getHTML()).toMatch(/color: (#dc2626|rgb.220, 38, 38.)/) + handlers.textColor!.execute(editor, {}).run() + expect(editor.getHTML()).not.toContain('color:') + + const yellow = { color: '#fef08a' } + handlers.highlight!.execute(editor, yellow).run() + expect(handlers.highlight!.isActive(editor, yellow)).toBe(true) + expect(handlers.highlight!.isActive(editor, {})).toBe(false) + handlers.highlight!.execute(editor, {}).run() + expect(editor.getHTML()).not.toContain(' { + const { editor, handlers } = setup('

x

') + expect(handlers.table!.canExecute(editor)).toBe(true) + handlers.table!.execute(editor).run() + expect(handlers.table!.isActive(editor)).toBe(true) + expect(editor.getJSON().content?.[0]?.content).toHaveLength(3) + + expect(handlers.tableAction!.canExecute(editor, { action: 'addRowAfter' })).toBe(true) + expect(handlers.tableAction!.canExecute(editor, { action: 'dropDatabase' })).toBe(false) + handlers.tableAction!.execute(editor, { action: 'addRowAfter' }).run() + expect(editor.getJSON().content?.[0]?.content).toHaveLength(4) + handlers.tableAction!.execute(editor, { action: 'deleteTable' }).run() + expect(editor.isActive('table')).toBe(false) + expect(handlers.tableAction!.isDisabled!(editor)).toBe(true) + }) + + it('toggles panels and modes without touching the document', () => { + const { editor, ui, handlers } = setup('

text

') + const before = editor.getHTML() + handlers.findReplace!.execute(editor).run() + expect(ui.panel).toBe('find') + expect(handlers.findReplace!.isActive(editor)).toBe(true) + handlers.findReplace!.execute(editor).run() + expect(ui.panel).toBeNull() + + handlers.sourceMode!.execute(editor).run() + expect(ui.mode).toBe('source') + handlers.preview!.execute(editor).run() + expect(ui.mode).toBe('preview') + expect(handlers.preview!.isActive(editor)).toBe(true) + handlers.preview!.execute(editor).run() + expect(ui.mode).toBe('edit') + + expect(handlers.link!.canExecute(editor)).toBe(false) + selectText(editor, 'text') + expect(handlers.link!.canExecute(editor)).toBe(true) + handlers.link!.execute(editor).run() + expect(ui.panel).toBe('link') + expect(editor.getHTML()).toBe(before) + }) + + it('opens AI actions through the ai handler', () => { + const open = vi.fn() + const ai = { + open, + isOpen: computed(() => true), + isGenerating: ref(false), + currentAction: ref({ id: 'improve', label: 'Improve' }), + } as unknown as Parameters[0]['ai'] + const { editor, handlers } = setup('

text

', ai) + expect(handlers.ai!.canExecute(editor, { action: 'improve' })).toBe(true) + expect(handlers.ai!.canExecute(editor, { action: 'missing' })).toBe(false) + handlers.ai!.execute(editor, { action: 'improve' }).run() + expect(open).toHaveBeenCalledWith({ id: 'improve', label: 'Improve' }) + expect(handlers.ai!.isActive(editor, { action: 'improve' })).toBe(false) + expect(handlers.ai!.isDisabled!(editor)).toBe(false) + }) + + it('omits the link override and AI handler when not requested', () => { + const handlers = createWysiwygHandlers({ + ui: { panel: null, mode: 'edit' }, + linkEditor: false, + }) + expect(handlers.link).toBeUndefined() + expect(handlers.ai).toBeUndefined() + }) +}) + +describe('toolbar, bubbles and slash menu', () => { + const ai = [{ id: 'improve', label: 'Improve', icon: 'i-lucide-wand' }] + + it('extends the default toolbar without dropping its groups', () => { + const toolbar = buildWysiwygToolbar({ features: DEFAULT_WYSIWYG_FEATURES, imagesEnabled: true }) + for (const item of DEFAULT_TOOLBAR.flat()) { + expect(kinds(toolbar)).toContain(item.kind) + } + expect(kinds(toolbar)).toEqual( + expect.arrayContaining(['table', 'findReplace', 'sourceMode', 'preview']), + ) + expect(kinds(toolbar)).not.toContain('ai') + }) + + it('drops entries of disabled features and images', () => { + const toolbar = buildWysiwygToolbar({ + features: resolveWysiwygFeatures(false), + imagesEnabled: false, + aiActions: ai, + aiLabel: 'Assistant', + }) + const all = kinds(toolbar) + for (const kind of ['taskList', 'textAlign', 'imageUpload', 'table', 'findReplace']) { + expect(all).not.toContain(kind) + } + expect(toolbar.at(-1)?.[0]).toMatchObject({ label: 'Assistant' }) + }) + + it('builds text bubble groups for formatting and AI independently', () => { + expect( + buildWysiwygTextBubble({ formatting: false, features: DEFAULT_WYSIWYG_FEATURES }), + ).toEqual([]) + const bubble = buildWysiwygTextBubble({ + formatting: true, + features: DEFAULT_WYSIWYG_FEATURES, + aiActions: ai, + }) + expect(bubble).toHaveLength(4) + expect(bubble[0]?.[0]).toMatchObject({ items: [{ kind: 'ai', action: 'improve' }] }) + expect(kinds(WYSIWYG_TABLE_BUBBLE as unknown[][]).every((k) => k === 'tableAction')).toBe(true) + }) + + it('exposes AI entries in the slash menu with an `ai` keyword', () => { + const items = buildWysiwygSlashItems({ + formatting: true, + features: DEFAULT_WYSIWYG_FEATURES, + imagesEnabled: true, + aiActions: ai, + }) + expect(items[0]?.[1]).toMatchObject({ + kind: 'ai', + action: 'improve', + keywords: 'ai ia assistant', + }) + expect(kinds(items)).toEqual(expect.arrayContaining(['table', 'imageUpload', 'taskList'])) + expect( + buildWysiwygSlashItems({ + formatting: false, + features: DEFAULT_WYSIWYG_FEATURES, + imagesEnabled: true, + }), + ).toEqual([]) + }) +}) + +describe('editor tools', () => { + it('reads editor state and only proposes operations', () => { + const editor = editorWith('

Intro

Some text here.

') + const tools = createWysiwygEditorTools(editor) + selectText(editor, 'text') + const before = editor.getHTML() + + expect(tools.getSelection.execute({})).toMatchObject({ text: 'text' }) + expect(tools.getDocumentText.execute({}).text).toBe('Intro\n\nSome text here.') + expect(tools.getCurrentBlock.execute({})).toEqual({ + type: 'paragraph', + text: 'Some text here.', + }) + expect(tools.getHeadings.execute({})).toEqual([{ level: 2, text: 'Intro', pos: 0 }]) + expect(tools.findText.execute({ query: 'TEXT' })).toEqual([ + { from: posOf(editor, 'text'), to: posOf(editor, 'text') + 4 }, + ]) + expect(tools.proposeReplaceSelection.execute({ content: 'x' })).toEqual({ + type: 'replaceSelection', + content: 'x', + }) + expect(tools.proposeInsertContent.execute({ content: 'x' })).toEqual({ + type: 'insertAtCursor', + content: 'x', + }) + expect(tools.proposeInsertContent.execute({ content: 'x', at: 1 })).toEqual({ + type: 'insertContent', + at: 1, + content: 'x', + }) + expect(tools.proposeReplaceText.execute({ from: 1, to: 2, content: 'x' })).toMatchObject({ + type: 'replaceText', + }) + expect(Object.values(tools).every((t) => t.kind === 'read' || t.kind === 'propose')).toBe(true) + expect(editor.getHTML()).toBe(before) + }) +}) + +describe('source mode helpers', () => { + it('serializes and validates HTML / JSON sources', () => { + const editor = editorWith('

hi

') + expect(serializeWysiwygSource(editor, 'html')).toBe('

hi

') + expect(JSON.parse(serializeWysiwygSource(editor, 'json')).type).toBe('doc') + expect(parseWysiwygSource('

x

', 'html')).toEqual({ ok: true, content: '

x

' }) + expect(parseWysiwygSource('{"type":"doc"}', 'json')).toEqual({ + ok: true, + content: { type: 'doc' }, + }) + expect(parseWysiwygSource('{', 'json')).toMatchObject({ ok: false }) + expect(parseWysiwygSource('[]', 'json')).toMatchObject({ ok: false }) + expect(parseWysiwygSource('{"type":"paragraph"}', 'json')).toMatchObject({ ok: false }) + }) +}) + +describe('AI output safety', () => { + it('rejects unsafe URLs', () => { + for (const url of [ + 'https://a.b', + 'http://a', + 'mailto:x@y.z', + 'tel:+33', + '#top', + '/p', + './p', + 'page.html', + ]) { + expect(isSafeWysiwygUrl(url)).toBe(true) + } + for (const url of [ + 'javascript:alert(1)', + ' java\tscript:alert(1)', + 'data:text/html,x', + 'vbscript:x', + '//evil.com', + '\\\\evil', + '', + ]) { + expect(isSafeWysiwygUrl(url)).toBe(false) + } + }) + + it('keeps allowed markup and strips scripts, handlers and unsafe links', () => { + const html = sanitizeWysiwygAiHtml( + '

Hi there

' + + 'badok' + + 'kept text' + + '
cd
', + ) + expect(html).toContain('

Hi there

') + expect(html).not.toMatch(/script|style|onclick|iframe|javascript|target/) + expect(html).toContain('bad') + expect(html).toContain('ok') + expect(html).toContain('kept text') + expect(html).toContain('cd') + }) + + it('falls back to escaped text without DOMParser', () => { + vi.stubGlobal('DOMParser', undefined) + try { + expect(sanitizeWysiwygAiHtml('x & ')).toBe('x & y') + } finally { + vi.unstubAllGlobals() + } + }) + + it('splits plain text into paragraphs and hard breaks', () => { + expect(textToWysiwygParagraphs('\r\nA\nB\n\n\nC \n\n')).toEqual([ + { + type: 'paragraph', + content: [{ type: 'text', text: 'A' }, { type: 'hardBreak' }, { type: 'text', text: 'B' }], + }, + { type: 'paragraph', content: [{ type: 'text', text: 'C' }] }, + ]) + expect(textToWysiwygParagraphs(' ')).toEqual([]) + }) +}) + +describe('editor styles', () => { + it('injects table / task list / search styles once and refreshes them', async () => { + const { ensureWysiwygEditorStyles, refreshWysiwygEditorStyles } = await import( + '../../src/advanced/wysiwyg/styles' + ) + ensureWysiwygEditorStyles() + ensureWysiwygEditorStyles() + expect(document.querySelectorAll('#jf-wysiwyg-editor')).toHaveLength(1) + refreshWysiwygEditorStyles() + const style = document.getElementById('jf-wysiwyg-editor') + expect(style?.textContent).toContain('.jf-wysiwyg-search-match-current') + expect(style?.textContent).toContain("ul[data-type='taskList']") + style?.remove() + }) +}) diff --git a/tests/advanced/wysiwygOptions.test.ts b/tests/advanced/wysiwygOptions.test.ts index 4e39032..879cd94 100644 --- a/tests/advanced/wysiwygOptions.test.ts +++ b/tests/advanced/wysiwygOptions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { DEFAULT_IMAGE_RESIZE, resolveWysiwygOptions } from '../../src/advanced/wysiwygOptions' -import { DEFAULT_TOOLBAR } from '../../src/advanced/wysiwygToolbar' +import { DEFAULT_WYSIWYG_FEATURES } from '../../src/advanced/wysiwyg/features' +import { buildWysiwygToolbar } from '../../src/advanced/wysiwyg/toolbar' describe('resolveWysiwygOptions', () => { it('derives safe defaults from the schema type', () => { @@ -10,7 +11,7 @@ describe('resolveWysiwygOptions', () => { expect(html).toMatchObject({ contentType: 'html', debounce: 300, - toolbar: DEFAULT_TOOLBAR, + toolbar: buildWysiwygToolbar({ features: DEFAULT_WYSIWYG_FEATURES, imagesEnabled: true }), density: 'compact', editorClass: '', minHeight: 'min-h-40', diff --git a/yarn.lock b/yarn.lock index bf02139..3004e2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1760,6 +1760,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-3.29.2.tgz#a7b392d7dd2cd463d9eda7556aaf0bb297795ad9" integrity sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A== +"@tiptap/extension-highlight@^3.29.2": + version "3.29.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-highlight/-/extension-highlight-3.29.2.tgz" + integrity sha512-bSeZ1C8OlthyK7BzgtqOGfQymdap9V38W2hbr9KGlhVOIAmTqneOwjm27iFcu7jKqkMArVu6WBYN18Lom6umHQ== + "@tiptap/extension-horizontal-rule@^3.27.3", "@tiptap/extension-horizontal-rule@^3.29.2": version "3.29.2" resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.29.2.tgz#a2afe200c29f9355fa1a79c96fc73bef016bbbd2" @@ -1827,6 +1832,21 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.29.2.tgz#fe17b6140955e8f45c7ad32c66539891be934441" integrity sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg== +"@tiptap/extension-table@^3.29.2": + version "3.29.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-3.29.2.tgz" + integrity sha512-HY3JWD8i/F8W1oWw56DMqEMqxScIAZFRrhL3rUwiuowHG8LLDqDdQjBV3w1DY8lUZOtNY9grpuMtGlPUFrOVAA== + +"@tiptap/extension-text-align@^3.29.2": + version "3.29.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text-align/-/extension-text-align-3.29.2.tgz" + integrity sha512-205FF87zjUEh79n6dgVy9SMj1P+rNserHMqPd8lF7YS2TOWikXPG4PFuQHWXXCYzaCvQXAiATSAyPXXmUWUvaQ== + +"@tiptap/extension-text-style@^3.29.2": + version "3.29.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text-style/-/extension-text-style-3.29.2.tgz" + integrity sha512-aQad9V9ROaEi3hE4g5z9wQ6lv5FSnzlnWFMVJxRjNlwXgm7H0fymxb4rGfca+pAwYkiRwpKYh1LatpJw2ECQAA== + "@tiptap/extension-text@^3.29.2": version "3.29.2" resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.29.2.tgz#7f590043b9044bd7cbc478c65e211bc3f2538335"