Skip to content

Rich Text Formatting: Design Spec

Overview

Inline formatting for text blocks, buttons, taglines, and flow operators (messages, choices, links, change-text). Bubble menu appears on text selection, similar to Linear/Notion.

Supported formatting: italic, underline, strikethrough, font size, font weight, color, uppercase toggle.

Bold is intentionally excluded as a toggle -- font weight covers the same range with more control. Cmd+B toggles fontWeight between 700 and the block's default weight.

Hybrid Storage Model

Two different storage strategies based on where the text lives:

Visual element blocks (TextProperties, ButtonProperties, TaglineProperties)

Format: ProseMirror JSON in a new richText field.

json
{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "Hello " },
        { "type": "text", "marks": [{ "type": "bold" }], "text": "world" },
        { "type": "text", "marks": [{ "type": "textStyle", "attrs": { "fontSize": "24px", "fontWeight": "700", "color": "#ff0000" } }], "text": "!" }
      ]
    }
  ]
}

Why ProseMirror JSON:

  • Structured data enables future extensions (per-word animations, DCO tokens)
  • Individual text nodes map directly to DOM nodes needed for write-on/word-pop animations
  • Can always extract HTML from JSON; going the other way is lossy

Fallback: Existing text: string is kept permanently. Creatives without richText render via escaped text as before. When a user edits an old creative, richText is populated on first save.

Flow operators (StatementOp, AnswerOp, LinkOp, ChangeTextOp)

Format: HTML string in existing inputText.value.

Hello <strong>world</strong><span style="font-size: 24px; color: #00ff00">!</span>

Why HTML string:

  • Engine already renders flow text via v-html through RichText.getRichText()
  • No new fields needed -- plain text is valid HTML, so backward compatible
  • Simpler data path through Creative Composer (no conversion step)
  • Can migrate to ProseMirror JSON later if needed

Editor

Library: Tiptap (@tiptap/vue-2, @tiptap/pm, @tiptap/starter-kit)

Only installed in Application-Frontend. Engine has zero Tiptap dependency.

Tiptap Extensions

ExtensionOutputPackage
Bold<strong>@tiptap/starter-kit (included)
Italic<em>@tiptap/starter-kit (included)
Underline<u>@tiptap/extension-underline
Strikethrough<s>@tiptap/starter-kit (included)
TextStyleMark container@tiptap/extension-text-style
PlaceholderPlaceholder text@tiptap/extensions
InlineStyleExtensionfontSize, fontWeight, color attrs on textStyleCustom (fontSizeExtension.ts)

InlineStyleExtension

Custom extension that adds three attributes to the textStyle mark:

AttributeparseHTMLrenderHTMLValidation in CE sanitizer
fontSizeelement.style.fontSizestyle="font-size: Xpx"Decimal number + px/rem/em/%
fontWeightelement.style.fontWeightstyle="font-weight: NNN"Exactly 3-digit number
colorelement.style.colorstyle="color: #rrggbb"Exactly 6-digit hex

Also provides a ProseMirror decoration plugin for visual font-size scaling in the editor (see "Font Size Scaling" below).

Bubble Menu

Appears on text selection. Custom-positioned relative to selection range.

Layout: [I] [U] [S] [TT] | [font-size] [weight-picker] | [color-swatch]

ControlAction
IToggle italic mark
UToggle underline mark
SToggle strikethrough mark
TTToggle uppercase (transforms actual text content, preserves marks)
Font size inputNumber input, sets textStyle fontSize in px
Weight pickerDropdown filtered by font family's available weights
Color swatchOpens ColorPicker (hex only, no alpha)

Keyboard Shortcuts

ShortcutActionNotes
Cmd+BToggle bold weightSets fontWeight to 700 or reverts to block default. Not Tiptap's built-in bold.
Cmd+SToggle strikethroughOnly when text is selected (prevents conflict with browser save)
Cmd+Shift+UToggle uppercaseTransforms actual characters, not CSS text-transform

Uppercase Toggle

Uses ProseMirror transactions to modify text content directly rather than CSS text-transform. This ensures uppercase text is stored as actual uppercase characters, making it visible in all contexts (preview, delivery, export).

The toggle walks selected text nodes, transforms each character, and replaces the text while preserving all marks. If all selected text is already uppercase, it converts to lowercase.

Font Size Scaling

Inline font sizes can't render at actual size in the compact editor (a 100px font would break the layout). Instead, a ProseMirror decoration plugin assigns CSS classes based on size ranges:

Actual sizeClassEditor scale
1-10px.fs-xs0.75em
11-14px.fs-sm0.88em
15-20px.fs-md1.00em
21-32px.fs-lg1.15em
33px+.fs-xl1.35em

These decorations only exist in the editor DOM -- they're never stored in JSON or HTML output. The actual font sizes render correctly in the preview and delivery.

Transparent Mode

Flow operators use transparent prop on RichTextEditor, which:

  • Removes the grey background
  • Inherits font-family, font-size, line-height from the parent operator element
  • Left-aligns text
  • Uses text cursor instead of grab cursor

This makes the editor blend seamlessly into the operator UI.

Integration Points

Text blocks, buttons, taglines (ContentSection.vue):

  • RichTextEditor with mode="json" outputs ProseMirror JSON
  • Connects to configurationLogic.updateValue() for Vuex persistence
  • inputLocked disables the editor via editor.setEditable(false)
  • defaultFontSize, defaultFontWeight, defaultColor from block styles
  • fontFamily used to filter available font weights in the weight picker

Flow operators (StatementOp, AnswerOp, LinkOp, ChangeTextOp):

  • RichTextEditor with mode="html" and transparent prop
  • editor.getHTML() output is cleaned via stripEmptySpans() before emitting
  • stripEmptySpans() removes bare <span> wrappers (Tiptap artifacts)
  • Stored in inputText.value as before

OperatorBase focus handling:

  • focusComponentHandler extended to recognize contenteditable elements
  • ProseMirror clicks now correctly set focusComponentInput = true, hiding BrandingToolbar

Randomly Mode Compatibility

The | split in Text.vue happens on the raw string. With HTML, a pipe inside a tag could cause issues:

Hello <strong>wor|ld</strong>  -- would split mid-tag

Since randomly mode is rarely used: document the limitation. Users who use randomly mode should avoid rich text formatting across the pipe boundary. This can be revisited later if needed.

Engine Rendering

jsonToHtml() converter

Lightweight function (~60 lines) that walks ProseMirror JSON and produces HTML. No Tiptap dependency. Located at CE/src/utils/content/jsonToHtml.ts.

Supports all marks: bold, italic, underline, strike, textStyle (fontSize, fontWeight, color). Text content is HTML-escaped. Empty textStyle marks (no attributes) produce no wrapper.

RichText.getRichText() sanitizer

Whitelist-based HTML sanitizer at CE/src/utils/content/richText.ts. Uses placeholder-and-escape strategy:

  1. Strip bare <span> tags (no style attribute)
  2. Replace allowed tags with null-byte placeholders
  3. Escape all remaining < and >
  4. Restore placeholders

Allowed tags: <strong>, <em>, <u>, <s>, <p>, and <span style="..."> with validated font-size/font-weight/color properties.

See architecture/rich-text-content-pipeline.md for the complete sanitizer reference with regex patterns.

Engine CSS

Blocks explicitly set fontWeight: 700 on & strong, & b and fontStyle: italic on & em, & i in their styles() computed. This ensures formatting works even when the parent block has an explicit fontWeight.

<u> and <s> use browser defaults (text-decoration) which aren't overridden by block styles.

Affected Files

Application-Frontend

FileChange
package.jsonAdd Tiptap dependencies
Blocks/data/types.tsAdd richText? field to TextProperties, ButtonProperties, TaglineProperties
Configuration/components/ContentSection.vueWire RichTextEditor with styling defaults
Configuration/configs/TextConfiguration.vuePass defaultColor from blockData
Configuration/configs/ButtonConfiguration.vuePass defaultColor from blockData
Configuration/configs/TaglineConfiguration.vuePass defaultColor, singleLine from blockData
CavaiFlow/operators/StatementOp.vueUse RichTextEditor with transparent mode
CavaiFlow/operators/AnswerOp.vueUse RichTextEditor with transparent mode
CavaiFlow/operators/LinkOp.vueUse RichTextEditor with transparent, singleLine
CavaiFlow/operators/ChangeTextOp.vueUse RichTextEditor with transparent mode
CavaiFlow/flowactors/OperatorBase.vueAdd contenteditable to focus detection
components/common/ColorPicker.vueAdd hideAlpha, hidePreview props

New files:

FilePurpose
components/common/RichTextEditor/RichTextEditor.vueShared Tiptap wrapper with bubble menu
components/common/RichTextEditor/fontSizeExtension.tsCustom extension: fontSize, fontWeight, color + decoration plugin

Creative-Engine

FileChange
utils/content/richText.tsWhitelist-based sanitizer (was escape-all)
utils/content/jsonToHtml.tsAdded fontWeight, color to textStyle handling
interfaces/jsonTypes/payload-v2/index.tsAdd richText? to Text, Button, Tagline types
components/creative/VisualElements/CreativeTextBlock.vuev-html with jsonToHtml + fallback
components/creative/VisualElements/CreativeButtonBlock.vuev-html with jsonToHtml + fallback
components/conversationflow/atoms/Tagline.vuev-html with jsonToHtml + fallback

No Backend Changes

Operator data and block properties are stored as schemaless JSON. No migration needed. The richText field and HTML in inputText.value are handled transparently.

Future Extensions

  • Feed/DCO variables as immutable inline tokens (Tiptap node views)
  • Text animations: write-on, word-pop (ProseMirror JSON gives individual DOM nodes per mark)
  • Migrate flow operators to ProseMirror JSON if more structure is needed
  • Links as inline marks (clickable text within a paragraph)
  • Google Fonts picker in TypefaceSection

Internal documentation