From d99b683a29ceff3c2707adfeab86b9f5a5a7cccb Mon Sep 17 00:00:00 2001 From: Tacx <12997062+tacxou@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:37:17 +0200 Subject: [PATCH 1/3] feat(advanced): store WYSIWYG content as Markdown Accept `options.contentType: 'markdown'` on string controls: UEditor serializes the document through TipTap's Markdown extension, source mode edits the raw Markdown and the builder inspector offers the format. Images and tables round-trip; resized image widths have no Markdown equivalent and are dropped. Prepare the 2.1.0 changelog, which also covers the unreleased rich WYSIWYG features and AI assistant. --- CHANGELOG.md | 17 ++++++++++ README.md | 4 +-- changelog/2.1.0.md | 22 +++++++++++++ docs/en/guide/options/string.md | 18 ++++++++++- docs/guide/options/string.md | 19 ++++++++++- package.json | 2 ++ playground/examples/items/nuxt-wysiwyg.ts | 23 +++++++++++++ src/advanced/wysiwyg/WysiwygSourceEditor.vue | 17 +++++++--- src/advanced/wysiwyg/source.ts | 14 +++++--- src/advanced/wysiwygOptions.ts | 7 ++-- src/builder/BuilderInspector.vue | 7 ++-- tests/advanced/wysiwygFeatures.test.ts | 34 ++++++++++++++++++++ tests/advanced/wysiwygOptions.test.ts | 3 ++ yarn.lock | 4 +-- 14 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 changelog/2.1.0.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0ff28..ffe5715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ versioning follows [SemVer](https://semver.org/). +## [2.1.0](https://github.com/FicSysFR/jsonforms_builder/compare/2.0.2...2.1.0) - 2026-10-19 + +### âš Features + +- WYSIWYG renderer: tables, task lists, alignment, colors, highlights, link panel, find & replace, + source mode, preview, an opt-in text bubble and an opt-in slash palette (`0de8d92`). +- Optional AI writing assistant over a host AI SDK endpoint (UI message stream, text stream or + JSON); results stay in a detached panel until accepted, requests are abortable and the feature + is inert unless `ai.enabled` is `true` (`0de8d92`). +- `options.contentType: 'markdown'` stores the WYSIWYG value as a Markdown string through TipTap's + Markdown extension; source mode edits the raw Markdown and the builder inspector offers the + Markdown format (`string` schema). + +### đ Documentation + +- WYSIWYG & AI guide, Markdown storage options and playground example (FR/EN). + ## [2.0.2](https://github.com/FicSysFR/jsonforms_builder/compare/2.0.1...2.0.2) - 2026-09-08 ### â ïž Breaking Changes diff --git a/README.md b/README.md index 53260a9..8d27dc0 100644 --- a/README.md +++ b/README.md @@ -74,14 +74,14 @@ Palette, drag-and-drop reorderable tree, property inspector, live preview, and J | `string` | `UInput` | | `string` + `options.multi` | `UTextarea` | | `string` + `format: password` | `UInput` + visibility toggle | -| `object` / `string` + `options.wysiwyg` | `UEditor` (JSON or HTML via `contentType`) | +| `object` / `string` + `options.wysiwyg` | `UEditor` (JSON, HTML or Markdown via `contentType`) | | WYSIWYG images | upload dropzone, drag-resize, bubble replace/delete | WYSIWYG options (`options.wysiwyg: true`): | Option | Role | |---|---| -| `contentType` | `'json'` \| `'html'` (else inferred from schema) | +| `contentType` | `'json'` \| `'html'` \| `'markdown'` (else inferred from schema) | | `toolbar` | Nuxt UI toolbar groups, or `false` to hide | | `density` | `'compact'` \| `'comfortable'` \| `'prose'` | | `minHeight` / `padding` / `blockSpacing` / `editorClass` | spacing overrides | diff --git a/changelog/2.1.0.md b/changelog/2.1.0.md new file mode 100644 index 0000000..ae032ab --- /dev/null +++ b/changelog/2.1.0.md @@ -0,0 +1,22 @@ +--- +version: 2.1.0 +date: 2026-10-19 +title: Rich WYSIWYG, AI assistant and Markdown content +previous: 2.0.2 +prerelease: false +--- + +### âš Features + +- WYSIWYG renderer: tables, task lists, alignment, colors, highlights, link panel, find & replace, + source mode, preview, an opt-in text bubble and an opt-in slash palette (`0de8d92`). +- Optional AI writing assistant over a host AI SDK endpoint (UI message stream, text stream or + JSON); results stay in a detached panel until accepted, requests are abortable and the feature + is inert unless `ai.enabled` is `true` (`0de8d92`). +- `options.contentType: 'markdown'` stores the WYSIWYG value as a Markdown string through TipTap's + Markdown extension; source mode edits the raw Markdown and the builder inspector offers the + Markdown format (`string` schema). + +### đ Documentation + +- WYSIWYG & AI guide, Markdown storage options and playground example (FR/EN). diff --git a/docs/en/guide/options/string.md b/docs/en/guide/options/string.md index 5b78822..2912a58 100644 --- a/docs/en/guide/options/string.md +++ b/docs/en/guide/options/string.md @@ -286,7 +286,7 @@ Requires `allRenderers` (not just `nuxtUiRenderers`). | Name | Type | Default | Description | |---|---|---|---| | `wysiwyg` | `Boolean` | â | **Required** (`true`) to enable the editor. | -| `contentType` | `"json"` \| `"html"` | inferred | Stored format: ProseMirror object (`json`) or HTML string (`html`). | +| `contentType` | `"json"` \| `"html"` \| `"markdown"` | inferred | Stored format: ProseMirror object (`json`), HTML string (`html`) or Markdown string (`markdown`, `string` schema). | | `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. | @@ -320,6 +320,22 @@ Editing features, the AI assistant and an AI SDK backend are detailed in } ``` +```json +{ + "type": "Control", + "scope": "#/properties/article", + "options": { + "wysiwyg": true, + "contentType": "markdown", + "density": "prose" + } +} +``` + +With `markdown`, the value is serialized by TipTap's Markdown extension (loaded by `UEditor`): +headings, lists, links, code, tables and images (``) round-trip; the width of a resized +image has no Markdown equivalent and is not kept. Source mode then edits the raw Markdown. + ## Playground - [String & Textarea](/en/playground#/?section=docs&example=nuxt-string) diff --git a/docs/guide/options/string.md b/docs/guide/options/string.md index 8228371..f7ad4af 100644 --- a/docs/guide/options/string.md +++ b/docs/guide/options/string.md @@ -286,7 +286,7 @@ NĂ©cessite `allRenderers` (pas seulement `nuxtUiRenderers`). | Name | Type | Default | Description | |---|---|---|---| | `wysiwyg` | `Boolean` | â | **Requis** (`true`) pour activer lâĂ©diteur. | -| `contentType` | `"json"` \| `"html"` | infĂ©rĂ© | Format stockĂ© : objet ProseMirror (`json`) ou chaĂźne HTML (`html`). | +| `contentType` | `"json"` \| `"html"` \| `"markdown"` | infĂ©rĂ© | Format stockĂ© : objet ProseMirror (`json`), chaĂźne HTML (`html`) ou chaĂźne Markdown (`markdown`, schema `string`). | | `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. | @@ -320,6 +320,23 @@ DĂ©tail des fonctions dâĂ©dition, de lâassistant IA et dâun backend AI SDK } ``` +```json +{ + "type": "Control", + "scope": "#/properties/article", + "options": { + "wysiwyg": true, + "contentType": "markdown", + "density": "prose" + } +} +``` + +En `markdown`, la valeur est sĂ©rialisĂ©e par l'extension Markdown de TipTap (chargĂ©e par `UEditor`) : +titres, listes, liens, code, tableaux et images (``) font l'aller-retour ; la largeur +d'une image redimensionnĂ©e n'a pas d'Ă©quivalent Markdown et n'est pas conservĂ©e. Le mode source +Ă©dite alors le Markdown brut. + ## Playground - [String & Textarea](/playground#/?section=docs&example=nuxt-string) diff --git a/package.json b/package.json index 4260d09..e1a54fe 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,8 @@ "@jsonforms/core": "^3.6.0", "@jsonforms/vue": "^3.6.0", "@nuxt/ui": "^4.8.1", + "@tiptap/extension-image": "^3.29.2", + "@tiptap/markdown": "^3.29.2", "@types/express": "^5.0.6", "@types/multer": "^2.0.0", "@types/node": "^22.10.0", diff --git a/playground/examples/items/nuxt-wysiwyg.ts b/playground/examples/items/nuxt-wysiwyg.ts index 741941c..223934f 100644 --- a/playground/examples/items/nuxt-wysiwyg.ts +++ b/playground/examples/items/nuxt-wysiwyg.ts @@ -11,6 +11,14 @@ export const data = { ], }, notesHtml: '
Notes HTML libresâŠ
', + article: [ + '## ProcĂ©dure', + '', + '1. Ouvrir le portail', + '2. Cliquer sur **Nouvelle demande**', + '', + '> Astuce : joignez une capture.', + ].join('\n'), } export const schema = { @@ -26,6 +34,11 @@ export const schema = { title: 'Notes (HTML)', description: 'contentType: "html" stocke une chaĂźne HTML.', }, + article: { + type: 'string', + title: 'Article (Markdown)', + description: 'contentType: "markdown" stocke une chaĂźne Markdown.', + }, }, } @@ -52,6 +65,16 @@ export const uischema = { showUnfocusedDescription: true, }, }, + { + type: 'Control', + scope: '#/properties/article', + options: { + wysiwyg: true, + contentType: 'markdown', + density: 'prose', + showUnfocusedDescription: true, + }, + }, ], } diff --git a/src/advanced/wysiwyg/WysiwygSourceEditor.vue b/src/advanced/wysiwyg/WysiwygSourceEditor.vue index b6ce81b..264ddd8 100644 --- a/src/advanced/wysiwyg/WysiwygSourceEditor.vue +++ b/src/advanced/wysiwyg/WysiwygSourceEditor.vue @@ -6,7 +6,7 @@ autoresize :maxrows="30" :readonly="!editable" - :aria-label="contentType === 'json' ? 'Source JSON' : 'Source HTML'" + :aria-label="sourceLabel" :ui="{ base: 'font-mono text-xs' }" ) u-alert(v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" :description="error") @@ -16,15 +16,16 @@ diff --git a/src/advanced/wysiwyg/source.ts b/src/advanced/wysiwyg/source.ts index 8539671..11b236e 100644 --- a/src/advanced/wysiwyg/source.ts +++ b/src/advanced/wysiwyg/source.ts @@ -1,18 +1,22 @@ import type { Editor, JSONContent } from '@tiptap/core' +import type { WysiwygContentType } from '../wysiwygOptions' 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() +export const serializeWysiwygSource = (editor: Editor, contentType: WysiwygContentType): string => { + if (contentType === 'json') return JSON.stringify(editor.getJSON(), null, 2) + if (contentType === 'markdown') return editor.getMarkdown() + return editor.getHTML() +} -/** Validates source-mode input; JSON must be a Tiptap `doc` node. */ +/** Validates source-mode input; JSON must be a Tiptap `doc` node, HTML and Markdown pass through. */ export const parseWysiwygSource = ( source: string, - contentType: 'html' | 'json', + contentType: WysiwygContentType, ): WysiwygSourceParseResult => { - if (contentType === 'html') return { ok: true, content: source } + if (contentType !== 'json') return { ok: true, content: source } let parsed: unknown try { parsed = JSON.parse(source) diff --git a/src/advanced/wysiwygOptions.ts b/src/advanced/wysiwygOptions.ts index ef00530..86b7a34 100644 --- a/src/advanced/wysiwygOptions.ts +++ b/src/advanced/wysiwygOptions.ts @@ -12,7 +12,10 @@ import { } from './wysiwyg/features' import { buildWysiwygToolbar } from './wysiwyg/toolbar' -export type WysiwygContentType = 'html' | 'json' +/** Stored value format: TipTap JSON (object), HTML or Markdown (strings). */ +export type WysiwygContentType = 'html' | 'json' | 'markdown' + +const CONTENT_TYPES: readonly WysiwygContentType[] = ['html', 'json', 'markdown'] export type WysiwygDensity = 'compact' | 'comfortable' | 'prose' @@ -163,7 +166,7 @@ export const resolveWysiwygOptions = ( const explicitContent = opts.contentType const contentType: WysiwygContentType = - explicitContent === 'html' || explicitContent === 'json' + explicitContent && CONTENT_TYPES.includes(explicitContent) ? explicitContent : schemaType === 'string' ? 'html' diff --git a/src/builder/BuilderInspector.vue b/src/builder/BuilderInspector.vue index ae81a32..0d8e5d3 100644 --- a/src/builder/BuilderInspector.vue +++ b/src/builder/BuilderInspector.vue @@ -476,7 +476,7 @@ p.text-xs.font-semibold.uppercase.tracking-wide.text-dimmed Options â texte riche u-form-field(label="Format stockĂ©" help="options.contentType â adapte aussi le type du schema") u-select( - :model-value="options.contentType === 'html' ? 'html' : 'json'" + :model-value="options.contentType === 'html' || options.contentType === 'markdown' ? options.contentType : 'json'" :items="wysiwygContentItems" value-key="value" class="w-full" @@ -699,6 +699,7 @@ export default defineComponent({ const wysiwygContentItems = [ { label: 'JSON (ProseMirror)', value: 'json' }, { label: 'HTML', value: 'html' }, + { label: 'Markdown', value: 'markdown' }, ] const asNumber = (value: unknown): number | undefined => @@ -799,10 +800,10 @@ export default defineComponent({ /** contentType drives both the option and the schema `type` (object vs string). */ const patchWysiwygContentType = (value: unknown) => { - const contentType = value === 'html' ? 'html' : 'json' + const contentType = value === 'html' || value === 'markdown' ? value : 'json' emit('update:control', { element: { options: { ...options.value, contentType } }, - property: { type: contentType === 'html' ? 'string' : 'object' }, + property: { type: contentType === 'json' ? 'object' : 'string' }, }) } diff --git a/tests/advanced/wysiwygFeatures.test.ts b/tests/advanced/wysiwygFeatures.test.ts index 8170483..6d5cf4b 100644 --- a/tests/advanced/wysiwygFeatures.test.ts +++ b/tests/advanced/wysiwygFeatures.test.ts @@ -1,7 +1,9 @@ // @vitest-environment jsdom import { Extension, type Editor } from '@tiptap/core' +import Image from '@tiptap/extension-image' import { TableKit } from '@tiptap/extension-table' +import { Markdown } from '@tiptap/markdown' import { afterEach, describe, expect, it, vi } from 'vitest' import { computed, reactive, ref } from 'vue' import { isSafeWysiwygUrl, sanitizeWysiwygAiHtml } from '../../src/advanced/wysiwyg/ai/sanitize' @@ -350,6 +352,38 @@ describe('source mode helpers', () => { expect(parseWysiwygSource('[]', 'json')).toMatchObject({ ok: false }) expect(parseWysiwygSource('{"type":"paragraph"}', 'json')).toMatchObject({ ok: false }) }) + + it('serializes and re-applies Markdown sources, images and tables included', () => { + const editor = createTestEditor('', [Markdown, Image, TableKit]) + editors.push(editor) + const source = [ + '## Title', + '', + 'Some **bold** text and a [link](https://example.com).', + '', + '- one', + '- two', + '', + '', + '', + '| A | B |', + '| --- | --- |', + '| 1 | 2 |', + ].join('\n') + + const parsed = parseWysiwygSource(source, 'markdown') + expect(parsed).toEqual({ ok: true, content: source }) + editor.commands.setContent(source, { contentType: 'markdown' }) + + expect(editor.getHTML()).toContain('