Appearance
Rich Text Formatting Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add inline text formatting (bold, italic, underline, strikethrough, font size) to text blocks, buttons, taglines, and flow operators.
Architecture: Tiptap editor in Application-Frontend replaces textareas/inputs. Text blocks store ProseMirror JSON in a new richText field; flow operators store HTML strings in existing inputText.value. Engine renders rich text via a custom jsonToHtml() converter for blocks and a whitelist-based sanitizer for flow content.
Tech Stack: Tiptap (ProseMirror), Vue 2, TypeScript
Spec: Cavai-Documentation/src/DocumentationTexts/todos/RichTextFormatting/rich-text-formatting-design.md
File Structure
Application-Frontend (new files)
| File | Responsibility |
|---|---|
src/components/common/RichTextEditor/RichTextEditor.vue | Shared Tiptap wrapper with bubble menu, formatting buttons, font-size input |
src/components/common/RichTextEditor/fontSizeExtension.ts | Custom Tiptap extension for inline font-size marks |
Application-Frontend (modified files)
| File | Change |
|---|---|
package.json | Add Tiptap dependencies |
src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts | Add optional richText field to TextProperties, ButtonProperties, TaglineProperties |
src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.ts | No change needed (undefined by default, not present in defaults) |
src/pages/Chatbots/components/BuilderVisuals/Configuration/components/ContentSection.vue | Replace TextArea/InputField with RichTextEditor |
src/pages/Chatbots/components/CavaiFlow/operators/StatementOp.vue | Replace textarea with RichTextEditor (HTML mode) |
src/pages/Chatbots/components/CavaiFlow/operators/AnswerOp.vue | Replace textarea with RichTextEditor (HTML mode) |
src/pages/Chatbots/components/CavaiFlow/operators/LinkOp.vue | Replace link name input with RichTextEditor (HTML mode) |
src/pages/Chatbots/components/CavaiFlow/operators/ChangeTextOp.vue | Replace textarea with RichTextEditor (HTML mode) |
Creative-Engine (new files)
| File | Responsibility |
|---|---|
src/utils/content/jsonToHtml.ts | ProseMirror JSON to HTML converter for visual element blocks |
tests/utils/content/jsonToHtml.test.ts | Unit tests for JSON-to-HTML conversion |
tests/utils/content/richText.test.ts | Unit tests for whitelist sanitizer |
Creative-Engine (modified files)
| File | Change |
|---|---|
src/utils/content/richText.ts | Whitelist-based sanitizer (allow formatting tags, escape everything else) |
src/utils/content/encodeDecode.ts | No change (existing functions kept as-is) |
src/components/creative/VisualElements/CreativeTextBlock.vue | Use v-html with jsonToHtml() when richText exists, fallback to |
src/components/creative/VisualElements/CreativeButtonBlock.vue | Same as above |
src/components/conversationflow/atoms/Tagline.vue | Same as above |
src/interfaces/jsonTypes/payload-v2/index.ts | Add richText? to TextProperties, ButtonProperties, TaglineProperties |
Chunk 1: Engine Utilities (Pure Functions)
Task 1: Create jsonToHtml converter with tests
Converts ProseMirror JSON to HTML string. Supports marks: bold, italic, underline, strikethrough, textStyle (fontSize).
Files:
Create:
Creative-Engine/src/utils/content/jsonToHtml.tsCreate:
Creative-Engine/tests/utils/content/jsonToHtml.test.ts[ ] Step 1: Write failing tests
ts
// tests/utils/content/jsonToHtml.test.ts
import { describe, it, expect } from 'vitest'
import { jsonToHtml } from '@/utils/content/jsonToHtml'
describe('jsonToHtml', () => {
it('returns empty string for undefined/null input', () => {
expect(jsonToHtml(undefined)).toBe('')
expect(jsonToHtml(null)).toBe('')
})
it('converts plain text', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello world' }],
},
],
}
expect(jsonToHtml(doc)).toBe('Hello world')
})
it('converts bold text', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Hello ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'world' },
],
},
],
}
expect(jsonToHtml(doc)).toBe('Hello <strong>world</strong>')
})
it('converts italic text', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', marks: [{ type: 'italic' }], text: 'emphasis' },
],
},
],
}
expect(jsonToHtml(doc)).toBe('<em>emphasis</em>')
})
it('converts underline text', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
],
},
],
}
expect(jsonToHtml(doc)).toBe('<u>underlined</u>')
})
it('converts strikethrough text', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', marks: [{ type: 'strike' }], text: 'struck' },
],
},
],
}
expect(jsonToHtml(doc)).toBe('<s>struck</s>')
})
it('converts fontSize via textStyle mark', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [{ type: 'textStyle', attrs: { fontSize: '24px' } }],
text: 'big',
},
],
},
],
}
expect(jsonToHtml(doc)).toBe('<span style="font-size: 24px">big</span>')
})
it('handles multiple marks on the same text', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [{ type: 'bold' }, { type: 'italic' }],
text: 'strong emphasis',
},
],
},
],
}
expect(jsonToHtml(doc)).toBe('<strong><em>strong emphasis</em></strong>')
})
it('handles multiple paragraphs with line breaks', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line 1' }],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line 2' }],
},
],
}
expect(jsonToHtml(doc)).toBe('Line 1\nLine 2')
})
it('escapes HTML entities in text content', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '<script>alert("xss")</script>' }],
},
],
}
expect(jsonToHtml(doc)).toBe('<script>alert("xss")</script>')
})
it('handles empty paragraph', () => {
const doc = {
type: 'doc',
content: [{ type: 'paragraph' }],
}
expect(jsonToHtml(doc)).toBe('')
})
it('handles hardBreak node', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Line 1' },
{ type: 'hardBreak' },
{ type: 'text', text: 'Line 2' },
],
},
],
}
expect(jsonToHtml(doc)).toBe('Line 1\nLine 2')
})
})- [ ] Step 2: Run tests to verify they fail
Run: cd Creative-Engine && npx vitest run tests/utils/content/jsonToHtml.test.ts Expected: FAIL (module not found)
- [ ] Step 3: Write the implementation
ts
// src/utils/content/jsonToHtml.ts
type Mark = {
type: string
attrs?: Record<string, string>
}
type ProseMirrorNode = {
type: string
text?: string
marks?: Mark[]
content?: ProseMirrorNode[]
attrs?: Record<string, unknown>
}
const escapeHtml = (str: string): string => {
return str.replace(/[<>&]/g, (m) => {
switch (m) {
case '<': return '<'
case '>': return '>'
case '&': return '&'
default: return m
}
})
}
const wrapWithMark = (html: string, mark: Mark): string => {
switch (mark.type) {
case 'bold':
return `<strong>${html}</strong>`
case 'italic':
return `<em>${html}</em>`
case 'underline':
return `<u>${html}</u>`
case 'strike':
return `<s>${html}</s>`
case 'textStyle': {
const fontSize = mark.attrs?.fontSize
if (fontSize) {
return `<span style="font-size: ${fontSize}">${html}</span>`
}
return html
}
default:
return html
}
}
const renderNode = (node: ProseMirrorNode): string => {
if (node.type === 'text') {
let html = escapeHtml(node.text || '')
if (node.marks) {
for (const mark of node.marks) {
html = wrapWithMark(html, mark)
}
}
return html
}
if (node.type === 'hardBreak') {
return '\n'
}
if (node.type === 'paragraph' || node.type === 'doc') {
if (!node.content) {
return ''
}
return node.content.map(renderNode).join('')
}
return ''
}
export const jsonToHtml = (doc: ProseMirrorNode | null | undefined): string => {
if (!doc || !doc.content) {
return ''
}
return doc.content
.map(renderNode)
.filter((s) => s !== undefined)
.join('\n')
}- [ ] Step 4: Run tests to verify they pass
Run: cd Creative-Engine && npx vitest run tests/utils/content/jsonToHtml.test.ts Expected: All tests PASS
- [ ] Step 5: Commit
bash
cd Creative-Engine
git add src/utils/content/jsonToHtml.ts tests/utils/content/jsonToHtml.test.ts
git commit -m "Add ProseMirror JSON to HTML converter for rich text rendering"Task 2: Update whitelist sanitizer with tests
Replace the blanket encodeHTMLTags() in RichText.getRichText() with a whitelist-based approach that allows formatting tags.
Files:
Modify:
Creative-Engine/src/utils/content/richText.tsCreate:
Creative-Engine/tests/utils/content/richText.test.ts[ ] Step 1: Write failing tests
ts
// tests/utils/content/richText.test.ts
import { describe, it, expect } from 'vitest'
import { RichText } from '@/utils/content/richText'
describe('RichText.getRichText', () => {
it('passes through plain text unchanged', () => {
expect(RichText.getRichText('Hello world')).toBe('Hello world')
})
it('allows <strong> tags', () => {
expect(RichText.getRichText('Hello <strong>world</strong>')).toBe(
'Hello <strong>world</strong>',
)
})
it('allows <em> tags', () => {
expect(RichText.getRichText('<em>emphasis</em>')).toBe('<em>emphasis</em>')
})
it('allows <u> tags', () => {
expect(RichText.getRichText('<u>underlined</u>')).toBe('<u>underlined</u>')
})
it('allows <s> tags', () => {
expect(RichText.getRichText('<s>struck</s>')).toBe('<s>struck</s>')
})
it('allows <span> with font-size style only', () => {
expect(RichText.getRichText('<span style="font-size: 24px">big</span>')).toBe(
'<span style="font-size: 24px">big</span>',
)
})
it('escapes <script> tags', () => {
expect(RichText.getRichText('<script>alert("xss")</script>')).toBe(
'<script>alert("xss")</script>',
)
})
it('escapes <div> tags', () => {
expect(RichText.getRichText('<div>content</div>')).toBe(
'<div>content</div>',
)
})
it('escapes <img> tags', () => {
expect(RichText.getRichText('<img src="x" onerror="alert(1)">')).toBe(
'<img src="x" onerror="alert(1)">',
)
})
it('allows nested formatting tags', () => {
expect(
RichText.getRichText('<strong><em>bold italic</em></strong>'),
).toBe('<strong><em>bold italic</em></strong>')
})
it('escapes span with non-font-size styles', () => {
expect(
RichText.getRichText('<span style="background: red">danger</span>'),
).toBe('<span style="background: red">danger</span>')
})
it('handles mixed allowed and disallowed tags', () => {
expect(
RichText.getRichText('<strong>bold</strong><script>xss</script>'),
).toBe('<strong>bold</strong><script>xss</script>')
})
})- [ ] Step 2: Run tests to verify they fail
Run: cd Creative-Engine && npx vitest run tests/utils/content/richText.test.ts Expected: Several tests FAIL (current implementation escapes all <>)
- [ ] Step 3: Write the implementation
ts
// src/utils/content/richText.ts
// -- Whitelist patterns --
const ALLOWED_TAGS = [
'strong',
'em',
'u',
's',
]
// Match opening/closing tags and self-closing tags
const buildTagPattern = (tag: string) =>
new RegExp(`<${tag}(\\s[^>]*)?>|</${tag}>`, 'g')
// Match <span style="font-size: Xpx"> or <span style="font-size: Xrem"> etc.
const FONT_SIZE_SPAN_PATTERN = /<span\s+style="font-size:\s*[\d.]+(px|rem|em|%)"\s*>/g
const CLOSING_SPAN_PATTERN = /<\/span>/g
// Placeholder system: temporarily replace allowed tags with placeholders,
// escape everything else, then restore placeholders.
const sanitizeHtml = (input: string): string => {
const placeholders: string[] = []
const placeholder = (match: string) => {
const idx = placeholders.length
placeholders.push(match)
return `\x00RICH${idx}\x00`
}
let result = input
// Protect allowed simple tags
for (const tag of ALLOWED_TAGS) {
result = result.replace(buildTagPattern(tag), placeholder)
}
// Protect font-size spans
result = result.replace(FONT_SIZE_SPAN_PATTERN, placeholder)
result = result.replace(CLOSING_SPAN_PATTERN, placeholder)
// Escape remaining angle brackets
result = result.replace(/[<>]/g, (m) => (m === '<' ? '<' : '>'))
// Restore placeholders
result = result.replace(/\x00RICH(\d+)\x00/g, (_, idx) => placeholders[parseInt(idx)])
return result
}
export const RichText = {
getRichText(plaintext: string): string {
return sanitizeHtml(plaintext)
},
}- [ ] Step 4: Run tests to verify they pass
Run: cd Creative-Engine && npx vitest run tests/utils/content/richText.test.ts Expected: All tests PASS
- [ ] Step 5: Verify existing engine behavior is not broken
Run: cd Creative-Engine && npx vitest run Expected: All existing tests still PASS
- [ ] Step 6: Commit
bash
cd Creative-Engine
git add src/utils/content/richText.ts tests/utils/content/richText.test.ts
git commit -m "Replace blanket HTML escaping with whitelist-based sanitizer for rich text"Task 3: Update engine types
Add optional richText field to the engine's payload types.
Files:
Modify:
Creative-Engine/src/interfaces/jsonTypes/payload-v2/index.ts[ ] Step 1: Find TextProperties, ButtonProperties, TaglineProperties in the engine types
Search for these types in the file. They mirror the AF types but may have different structures.
- [ ] Step 2: Add
richText?field to each type
Add to TextProperties, ButtonProperties, and TaglineProperties (the expandable initial tagline type):
ts
richText?: Record<string, unknown> // ProseMirror JSON document- [ ] Step 3: Verify build
Run: cd Creative-Engine && npx vue-tsc --noEmit Expected: No type errors
- [ ] Step 4: Commit
bash
cd Creative-Engine
git add src/interfaces/jsonTypes/payload-v2/index.ts
git commit -m "Add optional richText field to block type definitions"Chunk 2: Engine Rendering
Task 4: Update CreativeTextBlock to render rich text
Files:
- Modify:
Creative-Engine/src/components/creative/VisualElements/CreativeTextBlock.vue
Currently uses (text interpolation). Needs to use v-html when richText exists.
- [ ] Step 1: Update the template
Replace the <span> content from:
html
<span :class="[blockClassNames.innerWrap]">
{{ parsedText }}
</span>To:
html
<span
:class="[blockClassNames.innerWrap]"
v-html="renderedContent"
/>- [ ] Step 2: Add jsonToHtml import and renderedContent computed
ts
import { jsonToHtml } from '@/utils/content/jsonToHtml'
// Add to computed:
renderedContent() {
if (this.typedBlock.richText) {
let richTextDoc = this.typedBlock.richText
if (this.feedSlideData) {
// For feed/DCO: convert to HTML first, then populate variables
let html = jsonToHtml(richTextDoc)
html = this.populateStringFromObject(html, this.feedSlideData, true)
return html
}
return jsonToHtml(richTextDoc)
}
// Fallback: plain text (existing behavior, escaped by Vue)
return this.escapeHtml(this.parsedText)
},Add a simple escapeHtml method to ensure the plain-text fallback path is safe when rendered via v-html:
ts
// Add to methods:
escapeHtml(str: string): string {
return str.replace(/[<>&]/g, (m) => {
switch (m) {
case '<': return '<'
case '>': return '>'
case '&': return '&'
default: return m
}
})
},- [ ] Step 3: Test locally
Open the builder with an existing creative (no richText field). Verify text blocks render identically to before.
- [ ] Step 4: Commit
bash
cd Creative-Engine
git add src/components/creative/VisualElements/CreativeTextBlock.vue
git commit -m "Render rich text in text blocks with plain-text fallback"Task 5: Update CreativeButtonBlock to render rich text
Files:
- Modify:
Creative-Engine/src/components/creative/VisualElements/CreativeButtonBlock.vue
Same pattern as Task 4. Currently uses on line 20.
- [ ] Step 1: Update template and add computed
Same changes as Task 4: replace with v-html="renderedContent", add jsonToHtml import, add renderedContent computed with richText check and fallback, add escapeHtml method.
- [ ] Step 2: Commit
bash
cd Creative-Engine
git add src/components/creative/VisualElements/CreativeButtonBlock.vue
git commit -m "Render rich text in button blocks with plain-text fallback"Task 6: Update Tagline to render rich text
Files:
- Modify:
Creative-Engine/src/components/conversationflow/atoms/Tagline.vue
Currently uses on line 30. Tagline receives text as a prop from the parent. The richText field lives on DataStore.creativeSettings.creativeBlocks.taglineProperties.
- [ ] Step 1: Update template
Replace:
html
{{ text }}With:
html
<span v-html="renderedContent" />- [ ] Step 2: Add computed
ts
import { jsonToHtml } from '@/utils/content/jsonToHtml'
// Add to computed:
renderedContent() {
const taglineProps = DataStore.creativeSettings.creativeBlocks.taglineProperties
if (taglineProps.richText) {
return jsonToHtml(taglineProps.richText)
}
// Fallback: escape plain text for safe v-html rendering
return (this.text || '').replace(/[<>&]/g, (m) => {
switch (m) {
case '<': return '<'
case '>': return '>'
case '&': return '&'
default: return m
}
})
},- [ ] Step 3: Commit
bash
cd Creative-Engine
git add src/components/conversationflow/atoms/Tagline.vue
git commit -m "Render rich text in taglines with plain-text fallback"Chunk 3: Application-Frontend Foundation
Task 7: Install Tiptap dependencies
Files:
Modify:
Application-Frontend/package.json[ ] Step 1: Install packages
bash
cd Application-Frontend
npm install @tiptap/vue-2 @tiptap/pm @tiptap/starter-kit @tiptap/extension-underline @tiptap/extension-text-style @tiptap/extension-bubble-menu- [ ] Step 2: Verify build still works
Run: cd Application-Frontend && npm run build Expected: Build succeeds
- [ ] Step 3: Commit
bash
cd Application-Frontend
git add package.json package-lock.json
git commit -m "Add Tiptap dependencies for rich text editing"Task 8: Create fontSize extension
Custom Tiptap extension that adds a fontSize attribute to the textStyle mark.
Files:
Create:
Application-Frontend/src/components/common/RichTextEditor/fontSizeExtension.ts[ ] Step 1: Write the extension
ts
// src/components/common/RichTextEditor/fontSizeExtension.ts
import { Extension } from '@tiptap/core'
export const FontSizeExtension = Extension.create({
name: 'fontSize',
addGlobalAttributes() {
return [
{
types: ['textStyle'],
attributes: {
fontSize: {
default: null,
parseHTML: (element) => element.style.fontSize || null,
renderHTML: (attributes) => {
if (!attributes.fontSize) {
return {}
}
return {
style: `font-size: ${attributes.fontSize}`,
}
},
},
},
},
]
},
})- [ ] Step 2: Commit
bash
cd Application-Frontend
git add src/components/common/RichTextEditor/fontSizeExtension.ts
git commit -m "Add custom Tiptap fontSize extension"Task 9: Create RichTextEditor component
Shared Tiptap wrapper with bubble menu. Supports two output modes:
jsonmode: emits ProseMirror JSON (for text blocks, buttons, taglines)htmlmode: emits HTML string (for flow operators)
Files:
Create:
Application-Frontend/src/components/common/RichTextEditor/RichTextEditor.vue[ ] Step 1: Write the component
vue
<template>
<div class="rich-text-editor" :class="{ disabled, 'single-line': singleLine }">
<BubbleMenu
v-if="editor && !disabled"
:editor="editor"
:tippy-options="{ duration: 150 }"
class="bubble-menu"
>
<button
:class="{ active: editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()"
>
B
</button>
<button
:class="{ active: editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()"
>
I
</button>
<button
:class="{ active: editor.isActive('underline') }"
@click="editor.chain().focus().toggleUnderline().run()"
>
U
</button>
<button
:class="{ active: editor.isActive('strike') }"
@click="editor.chain().focus().toggleStrike().run()"
>
S
</button>
<span class="separator" />
<input
v-model="fontSizeInput"
type="number"
class="font-size-input"
:placeholder="currentFontSize || ''"
min="1"
max="999"
@keydown.enter="applyFontSize"
@blur="applyFontSize"
>
</BubbleMenu>
<EditorContent :editor="editor" class="editor-content" />
</div>
</template>
<script lang="ts">
import { Editor, EditorContent, BubbleMenu } from '@tiptap/vue-2'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import TextStyle from '@tiptap/extension-text-style'
import { FontSizeExtension } from './fontSizeExtension'
import Vue from 'vue'
export default Vue.extend({
name: 'RichTextEditor',
components: {
EditorContent,
BubbleMenu,
},
props: {
value: {
type: [String, Object],
default: '',
},
mode: {
type: String,
default: 'json',
validator: (v: string) => ['json', 'html'].includes(v),
},
disabled: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
singleLine: {
type: Boolean,
default: false,
},
},
data() {
return {
editor: null as Editor | null,
fontSizeInput: '',
skipNextUpdate: false,
}
},
computed: {
currentFontSize(): string {
if (!this.editor) {
return ''
}
const attrs = this.editor.getAttributes('textStyle')
return attrs?.fontSize ? parseInt(attrs.fontSize, 10).toString() : ''
},
},
watch: {
value(newValue) {
if (this.skipNextUpdate) {
this.skipNextUpdate = false
return
}
if (!this.editor) {
return
}
if (this.mode === 'json') {
const currentJson = JSON.stringify(this.editor.getJSON())
const newJson = JSON.stringify(newValue)
if (currentJson !== newJson && newValue) {
this.editor.commands.setContent(newValue, false)
}
} else {
const currentHtml = this.editor.getHTML()
if (currentHtml !== newValue) {
this.editor.commands.setContent(newValue || '', false)
}
}
},
disabled(newValue) {
if (this.editor) {
this.editor.setEditable(!newValue)
}
},
},
mounted() {
const extensions = [
StarterKit.configure({
// Disable features we don't need
heading: false,
blockquote: false,
bulletList: false,
orderedList: false,
codeBlock: false,
code: false,
horizontalRule: false,
// Keep hardBreak for shift+enter
hardBreak: this.singleLine ? false : undefined,
}),
Underline,
TextStyle,
FontSizeExtension,
]
const content = this.mode === 'json'
? (this.value || { type: 'doc', content: [{ type: 'paragraph' }] })
: (this.value || '')
this.editor = new Editor({
extensions,
content,
editable: !this.disabled,
onUpdate: ({ editor }) => {
this.skipNextUpdate = true
const output = this.mode === 'json' ? editor.getJSON() : editor.getHTML()
this.$emit('input', output)
},
onFocus: () => {
this.$emit('focus')
},
onBlur: () => {
this.$emit('blur')
},
})
// Handle single-line: prevent Enter from creating new paragraphs
if (this.singleLine && this.editor) {
this.editor.setOptions({
editorProps: {
handleKeyDown: (_view, event) => {
if (event.key === 'Enter') {
return true // Prevent default
}
return false
},
},
})
}
},
beforeDestroy() {
if (this.editor) {
this.editor.destroy()
}
},
methods: {
applyFontSize() {
if (!this.editor || !this.fontSizeInput) {
return
}
const size = parseInt(this.fontSizeInput, 10)
if (isNaN(size) || size <= 0) {
return
}
this.editor.chain().focus().setMark('textStyle', { fontSize: `${size}px` }).run()
this.fontSizeInput = ''
},
focus() {
if (this.editor) {
this.editor.commands.focus()
}
},
},
})
</script>
<style scoped lang="scss">
.rich-text-editor {
width: 100%;
&.disabled {
opacity: 0.6;
pointer-events: none;
}
}
.bubble-menu {
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
background: var(--surface-overlay);
border: 1px solid var(--border-subtle);
border-radius: $border-radius-base;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-primary);
font-weight: 600;
font-size: 13px;
cursor: pointer;
&:hover {
background: var(--surface-hover);
}
&.active {
background: var(--accent-primary);
color: var(--text-on-accent);
}
}
.separator {
width: 1px;
height: 20px;
background: var(--border-subtle);
margin: 0 4px;
}
.font-size-input {
width: 40px;
height: 28px;
border: 1px solid var(--border-subtle);
border-radius: 4px;
background: var(--surface-base);
color: var(--text-primary);
font-size: 12px;
text-align: center;
outline: none;
&:focus {
border-color: var(--accent-primary);
}
// Hide number input spinners
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
-moz-appearance: textfield;
}
}
// Style the Tiptap editor content area to match existing textareas
.editor-content {
:deep(.ProseMirror) {
outline: none;
min-height: 1.5em;
color: var(--text-primary);
font-size: inherit;
font-family: inherit;
p {
margin: 0;
}
// Placeholder
&.is-empty::before {
content: attr(data-placeholder);
color: var(--text-secondary);
pointer-events: none;
float: left;
height: 0;
}
}
}
// Single-line mode
.single-line {
.editor-content {
:deep(.ProseMirror) {
white-space: nowrap;
overflow: hidden;
}
}
}
</style>- [ ] Step 2: Verify it renders without errors
Import the component in a test page or storybook, pass some plain text, verify the editor renders and the bubble menu appears on selection.
- [ ] Step 3: Commit
bash
cd Application-Frontend
git add src/components/common/RichTextEditor/RichTextEditor.vue
git commit -m "Add RichTextEditor component with bubble menu for inline formatting"Task 10: Add richText field to AF types
Files:
Modify:
Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts[ ] Step 1: Add
richText?to TextProperties, ButtonProperties, TaglineProperties
After the text: string line in each type, add:
ts
richText?: Record<string, unknown>TextProperties (line ~450), ButtonProperties (line ~473), TaglineProperties (line ~409).
- [ ] Step 2: Verify build
Run: cd Application-Frontend && npm run build Expected: Build succeeds
- [ ] Step 3: Commit
bash
cd Application-Frontend
git add src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts
git commit -m "Add optional richText field to text, button, and tagline types"Chunk 4: Builder Integration
Task 11: Update ContentSection to use RichTextEditor
Replace TextArea/InputField with RichTextEditor in JSON mode.
Files:
Modify:
Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/components/ContentSection.vue[ ] Step 1: Update the component
Replace the entire template and script with:
vue
<template>
<OptionRow
v-bind="overrideProps"
:title="`${hideTitle ? '' : $t('visuals.content')}`"
no-toggle
v-on="overrideHandlers"
>
<template #additional-content>
<RichTextEditor
ref="input"
:value="richText || plainTextAsJson"
mode="json"
:disabled="inputLocked"
@input="onRichTextUpdate"
@focus="$emit('focus')"
@blur="$emit('blur')"
/>
</template>
</OptionRow>
</template>
<script lang="ts">
import RichTextEditor from '@/components/common/RichTextEditor/RichTextEditor.vue'
import { sectionLogic } from '@/pages/Chatbots/components/BuilderVisuals/Configuration/mixins/sectionLogic'
import OptionRow from '@/pages/Chatbots/components/OptionRow/OptionRow.vue'
export default {
name: 'ContentSection',
components: { RichTextEditor, OptionRow },
mixins: [sectionLogic],
props: {
text: String,
richText: Object,
rich: Boolean,
customStyle: Object,
hideTitle: {
type: Boolean,
default: false,
},
autoHeight: {
type: Boolean,
default: false,
},
autoHeightMaxLines: {
type: Number,
default: 8,
},
singleLine: {
type: Boolean,
default: false,
},
},
computed: {
// Convert existing plain text to a minimal ProseMirror doc for the editor
plainTextAsJson() {
const text = this.text || ''
const paragraphs = text.split('\n').map((line) => ({
type: 'paragraph',
content: line ? [{ type: 'text', text: line }] : [],
}))
return {
type: 'doc',
content: paragraphs.length ? paragraphs : [{ type: 'paragraph' }],
}
},
},
methods: {
onRichTextUpdate(json) {
// Emit richText JSON for storage
this.$emit('update:richText', json)
// Also extract plain text for the `text` field (backwards compatibility)
const plainText = this.extractPlainText(json)
this.$emit('update:value', plainText)
},
extractPlainText(doc) {
if (!doc || !doc.content) {
return ''
}
return doc.content
.map((paragraph) => {
if (!paragraph.content) {
return ''
}
return paragraph.content
.filter((node) => node.type === 'text')
.map((node) => node.text || '')
.join('')
})
.join('\n')
},
focus() {
this.$refs.input.focus()
},
},
}
</script>
<style scoped lang="scss">
.content-input {
width: 100%;
}
</style>- [ ] Step 2: Update parent components that use ContentSection
Check TextConfiguration.vue, ButtonConfiguration.vue, TaglineConfiguration.vue, and FormSubmitButtonConfiguration.vue. Each needs to pass :rich-text and handle @update:richText.
For each parent, add:
:rich-text="blockData.richText"prop@update:richText="updateValue('richText', $event)"handler
Example for TextConfiguration (find the <ContentSection> usage):
html
<ContentSection
:text="blockData.text"
:rich-text="blockData.richText"
rich
@update:value="updateValue('text', $event)"
@update:richText="updateValue('richText', $event)"
/>- [ ] Step 3: Test locally
- Open builder with a text block
- Verify the Tiptap editor appears instead of a textarea
- Select text, verify the bubble menu appears
- Apply bold, italic, underline, strikethrough
- Verify the creative preview renders the formatting
- [ ] Step 4: Commit
bash
cd Application-Frontend
git add src/pages/Chatbots/components/BuilderVisuals/Configuration/components/ContentSection.vue
git add src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/TextConfiguration.vue
git add src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/ButtonConfiguration.vue
git commit -m "Integrate RichTextEditor into text block and button configuration panels"Task 12: Update StatementOp to use RichTextEditor
Replace the native <textarea> with RichTextEditor in HTML mode.
Files:
Modify:
Application-Frontend/src/pages/Chatbots/components/CavaiFlow/operators/StatementOp.vue[ ] Step 1: Update the template
Replace the textarea wrapper:
html
<div class="op-textarea-wrapper">
<FlowOpNewLines :text="inputText.value" />
<textarea ... />
</div>With:
html
<div class="op-textarea-wrapper">
<RichTextEditor
:value="inputText.value"
mode="html"
:placeholder="op.properties.randomly ? 'Enter texts separated by | ...' : 'Enter text ...'"
@input="onRichTextInput"
/>
</div>- [ ] Step 2: Update the script
Remove FlowOpNewLines import and component registration. Add RichTextEditor import. Remove expandInputHeight and collapseInputHeight methods (Tiptap handles its own sizing). Add onRichTextInput method:
ts
onRichTextInput(html: string) {
// eslint-disable-next-line vue/no-mutating-props
this.inputText.value = html
this.$emit(`update:${this.inputTextField}`, html)
},- [ ] Step 3: Test locally
- Open the flow editor
- Add a Statement operator
- Type text, select it, verify bubble menu appears
- Apply formatting
- Preview the creative, verify formatted text renders in conversation bubbles
- [ ] Step 4: Commit
bash
cd Application-Frontend
git add src/pages/Chatbots/components/CavaiFlow/operators/StatementOp.vue
git commit -m "Replace textarea with RichTextEditor in StatementOp"Task 13: Update AnswerOp to use RichTextEditor
Same pattern as StatementOp. Also fix the broken $emit template literal.
Files:
Modify:
Application-Frontend/src/pages/Chatbots/components/CavaiFlow/operators/AnswerOp.vue[ ] Step 1: Apply same changes as StatementOp
Replace textarea with RichTextEditor (HTML mode), remove FlowOpNewLines, add onRichTextInput method.
Also fix the broken emit on the tracking URL input (line 36):
html
<!-- Before (broken): -->
@input="$emit('update:`${trackingUrlField}`')"
<!-- After (fixed): -->
@input="$emit(`update:${trackingUrlField}`, $event)"- [ ] Step 2: Test locally
Same testing as StatementOp but for answer/choice bubbles.
- [ ] Step 3: Commit
bash
cd Application-Frontend
git add src/pages/Chatbots/components/CavaiFlow/operators/AnswerOp.vue
git commit -m "Replace textarea with RichTextEditor in AnswerOp and fix broken emit"Task 14: Update LinkOp to use RichTextEditor
Only the link display name (link.name) gets rich text. The URL input stays as a plain <input>.
Files:
Modify:
Application-Frontend/src/pages/Chatbots/components/CavaiFlow/operators/LinkOp.vue[ ] Step 1: Replace the link name input
Replace:
html
<input
:id="`link-name-${opKey}`"
v-model="link.name"
class="link-name"
placeholder="Link name"
...
>With:
html
<RichTextEditor
:value="link.name"
mode="html"
placeholder="Link name"
single-line
@input="onLinkNameInput"
/>Add method:
ts
onLinkNameInput(html: string) {
// eslint-disable-next-line vue/no-mutating-props
this.link.name = html
this.$emit(`update:${this.linkNameField}`, html)
},Also fix the broken emits (lines 13, 22, 31).
- [ ] Step 2: Commit
bash
cd Application-Frontend
git add src/pages/Chatbots/components/CavaiFlow/operators/LinkOp.vue
git commit -m "Replace link name input with RichTextEditor in LinkOp and fix broken emits"Task 15: Update ChangeTextOp to use RichTextEditor
Same pattern as StatementOp.
Files:
Modify:
Application-Frontend/src/pages/Chatbots/components/CavaiFlow/operators/ChangeTextOp.vue[ ] Step 1: Apply same changes as StatementOp
Replace textarea with RichTextEditor (HTML mode), remove FlowOpNewLines, add onRichTextInput method. Keep the TargetOpSelector component as-is.
- [ ] Step 2: Commit
bash
cd Application-Frontend
git add src/pages/Chatbots/components/CavaiFlow/operators/ChangeTextOp.vue
git commit -m "Replace textarea with RichTextEditor in ChangeTextOp"Chunk 5: Integration Testing & Polish
Task 16: End-to-end verification
No code changes. Manual testing checklist.
- [ ] Step 1: Test text block rich text (builder)
- Create a new creative with a text block
- Type text in the content section
- Select text, verify bubble menu appears with B/I/U/S buttons and font-size input
- Apply each formatting option
- Verify the preview iframe renders the formatting correctly
- Save the creative
- Reload the page, verify the formatting persists
- [ ] Step 2: Test button rich text (builder)
Same as above but with a button block.
- [ ] Step 3: Test flow operator rich text
- Open the flow editor
- Add Statement, Answer, and Link operators
- Type text and apply formatting in each
- Preview the creative
- Verify messages render with formatting
- Verify choices render with formatting
- Verify link display text renders with formatting
- [ ] Step 4: Test backwards compatibility
- Open an existing creative that has no
richTextfields - Verify text blocks render identically to before
- Edit the text, verify the RichTextEditor populates correctly from the plain
textfield - Save, verify both
textandrichTextfields are now stored
- [ ] Step 5: Test ChangeText operator
- Create a creative with a text block
- Add a flow with a ChangeText operator targeting the text block
- Apply formatting in the ChangeText operator
- Preview and verify the text block updates with formatting
- [ ] Step 6: Test on external site
Build and deploy to a test environment. Load a creative with rich text on an external site. Verify rendering.
- [ ] Step 7: Verify no console errors
Check browser console throughout all testing for any warnings or errors.
Task 17: Style adjustments
Fine-tune the RichTextEditor styling to match the existing builder UI.
Files:
Modify:
Application-Frontend/src/components/common/RichTextEditor/RichTextEditor.vuePossibly:
Application-Frontend/src/styles/(global styles for flow operator editors)[ ] Step 1: Match flow operator textarea styling
The RichTextEditor in flow operators needs to look like the existing textareas. Add CSS that matches the operator card's textarea styling (height, padding, border, background).
- [ ] Step 2: Match builder config panel styling
The RichTextEditor in ContentSection needs to look like the existing TextArea component (Vuetify v-textarea styling).
- [ ] Step 3: Commit
bash
cd Application-Frontend
git add -A
git commit -m "Polish RichTextEditor styling for builder and flow operator contexts"