Skip to content

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)

FileResponsibility
src/components/common/RichTextEditor/RichTextEditor.vueShared Tiptap wrapper with bubble menu, formatting buttons, font-size input
src/components/common/RichTextEditor/fontSizeExtension.tsCustom Tiptap extension for inline font-size marks

Application-Frontend (modified files)

FileChange
package.jsonAdd Tiptap dependencies
src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.tsAdd optional richText field to TextProperties, ButtonProperties, TaglineProperties
src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.tsNo change needed (undefined by default, not present in defaults)
src/pages/Chatbots/components/BuilderVisuals/Configuration/components/ContentSection.vueReplace TextArea/InputField with RichTextEditor
src/pages/Chatbots/components/CavaiFlow/operators/StatementOp.vueReplace textarea with RichTextEditor (HTML mode)
src/pages/Chatbots/components/CavaiFlow/operators/AnswerOp.vueReplace textarea with RichTextEditor (HTML mode)
src/pages/Chatbots/components/CavaiFlow/operators/LinkOp.vueReplace link name input with RichTextEditor (HTML mode)
src/pages/Chatbots/components/CavaiFlow/operators/ChangeTextOp.vueReplace textarea with RichTextEditor (HTML mode)

Creative-Engine (new files)

FileResponsibility
src/utils/content/jsonToHtml.tsProseMirror JSON to HTML converter for visual element blocks
tests/utils/content/jsonToHtml.test.tsUnit tests for JSON-to-HTML conversion
tests/utils/content/richText.test.tsUnit tests for whitelist sanitizer

Creative-Engine (modified files)

FileChange
src/utils/content/richText.tsWhitelist-based sanitizer (allow formatting tags, escape everything else)
src/utils/content/encodeDecode.tsNo change (existing functions kept as-is)
src/components/creative/VisualElements/CreativeTextBlock.vueUse v-html with jsonToHtml() when richText exists, fallback to
src/components/creative/VisualElements/CreativeButtonBlock.vueSame as above
src/components/conversationflow/atoms/Tagline.vueSame as above
src/interfaces/jsonTypes/payload-v2/index.tsAdd 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.ts

  • Create: 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('&lt;script&gt;alert("xss")&lt;/script&gt;')
  })

  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 '&lt;'
      case '>': return '&gt;'
      case '&': return '&amp;'
      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.ts

  • Create: 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(
      '&lt;script&gt;alert("xss")&lt;/script&gt;',
    )
  })

  it('escapes <div> tags', () => {
    expect(RichText.getRichText('<div>content</div>')).toBe(
      '&lt;div&gt;content&lt;/div&gt;',
    )
  })

  it('escapes <img> tags', () => {
    expect(RichText.getRichText('<img src="x" onerror="alert(1)">')).toBe(
      '&lt;img src="x" onerror="alert(1)"&gt;',
    )
  })

  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('&lt;span style="background: red"&gt;danger&lt;/span&gt;')
  })

  it('handles mixed allowed and disallowed tags', () => {
    expect(
      RichText.getRichText('<strong>bold</strong><script>xss</script>'),
    ).toBe('<strong>bold</strong>&lt;script&gt;xss&lt;/script&gt;')
  })
})
  • [ ] 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 === '<' ? '&lt;' : '&gt;'))

  // 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 '&lt;'
      case '>': return '&gt;'
      case '&': return '&amp;'
      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 '&lt;'
      case '>': return '&gt;'
      case '&': return '&amp;'
      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:

  • json mode: emits ProseMirror JSON (for text blocks, buttons, taglines)
  • html mode: 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
  1. Open builder with a text block
  2. Verify the Tiptap editor appears instead of a textarea
  3. Select text, verify the bubble menu appears
  4. Apply bold, italic, underline, strikethrough
  5. 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
  1. Open the flow editor
  2. Add a Statement operator
  3. Type text, select it, verify bubble menu appears
  4. Apply formatting
  5. 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)
  1. Create a new creative with a text block
  2. Type text in the content section
  3. Select text, verify bubble menu appears with B/I/U/S buttons and font-size input
  4. Apply each formatting option
  5. Verify the preview iframe renders the formatting correctly
  6. Save the creative
  7. 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
  1. Open the flow editor
  2. Add Statement, Answer, and Link operators
  3. Type text and apply formatting in each
  4. Preview the creative
  5. Verify messages render with formatting
  6. Verify choices render with formatting
  7. Verify link display text renders with formatting
  • [ ] Step 4: Test backwards compatibility
  1. Open an existing creative that has no richText fields
  2. Verify text blocks render identically to before
  3. Edit the text, verify the RichTextEditor populates correctly from the plain text field
  4. Save, verify both text and richText fields are now stored
  • [ ] Step 5: Test ChangeText operator
  1. Create a creative with a text block
  2. Add a flow with a ChangeText operator targeting the text block
  3. Apply formatting in the ChangeText operator
  4. 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.vue

  • Possibly: 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"

Internal documentation