# WhatsApp Editor — deep dive

`WhatsappEditor` is a headless controller for composing **WhatsApp messages**.
The key idea: a WhatsApp message is *plain text with lightweight inline markers*,
not a rich-text document. The `<textarea>` value **is** the string you send to
the WhatsApp API. The controller never invents an HTML document model — it only:

1. wraps/unwraps the selection with markers (toolbar + shortcuts),
2. inserts emoji at the caret (optional, pluggable picker),
3. renders a safe, live **preview** of how WhatsApp will display the markers.

## Why not a contenteditable / ProseMirror / tiptap editor?

Because the output you need is the marker string (`*bold*`), not HTML. A
contenteditable WYSIWYG would force a fragile DOM→markers serializer, and
ProseMirror/tiptap (~45 KB gzip + peer deps) model a rich document tree — the
wrong abstraction for plain text with markers, and incompatible with the
"enhance the DOM the consumer provides" headless contract. The textarea-as-source
approach makes serialization trivial and robust.

## Markup

```html
<div data-c42-whatsapp-editor>
  <div class="c42-whatsapp-editor-surface">
    <div data-c42-wa-toolbar>
      <button data-c42-wa-command="bold" aria-label="Bold"><b>B</b></button>
      <button data-c42-wa-command="italic" aria-label="Italic"><i>I</i></button>
      <button data-c42-wa-command="strikethrough" aria-label="Strikethrough">S</button>
      <button data-c42-wa-command="monospace" aria-label="Monospace">&lt;/&gt;</button>
      <button data-c42-wa-command="blockquote" aria-label="Quote">&gt;</button>
      <button data-c42-wa-command="bullet" aria-label="Bulleted list">•</button>
      <button data-c42-wa-command="ordered" aria-label="Numbered list">1.</button>
      <button data-c42-wa-command="clear" aria-label="Clear formatting">⌫</button>
    </div>
    <textarea data-c42-wa-input placeholder="Write a message…"></textarea>
    <div class="c42-whatsapp-editor-footer">
      <span data-c42-wa-counter></span>
      <span class="c42-whatsapp-editor-emoji">
        <button data-c42-wa-trigger aria-label="Emoji">🙂</button>
        <div data-c42-wa-picker hidden></div>
      </span>
    </div>
  </div>
  <!-- preview is a separate block, outside the editor card -->
  <div data-c42-wa-preview aria-live="polite"></div>
</div>
```

`.c42-whatsapp-editor-surface` wraps the toolbar, input and footer into the
editor "card" (white surface, rounded, subtle shadow, toolbar fixed at the top
behind a divider, borderless integrated textarea). The preview sits **outside**
the surface as a sibling so it reads as a separate element below the editor.
The wrapper is presentational only — the controller resolves its parts by
`data-*`, so it works with or without it.

Only `[data-c42-wa-input]` is required. Toolbar, picker, counter and preview are
all optional — wire only what you need. The `c42-whatsapp-editor-emoji` wrapper
is a themed, positioned anchor so the picker drops bottom-right and stays closed
until tapped; the controller finds its parts by `data-*` regardless of layout.

## Supported syntax

| Markup | Renders as | Notes |
| --- | --- | --- |
| `*bold*` | **bold** | inline |
| `_italic_` | _italic_ | inline |
| `~strikethrough~` | ~~strike~~ | inline |
| `` `mono` `` | `mono` | inline code |
| ```` ```block``` ```` | code block | fenced, multi-line |
| `> quote` | blockquote | line prefix |
| `- ` / `* ` | bullet list | line prefix |
| `1. ` | numbered list | line prefix |

Inline markers must hug non-space content (`*bold*`, not `* bold *`), matching
WhatsApp. Markers nest (`*_x_*` → bold+italic). A leading `* ` or `- ` is always
a **bullet**, never inline bold — block parsing happens line-first.

## Toolbar & keyboard

There are two kinds of toolbar command, both via `data-c42-wa-command`:

- **Inline markers** (`bold`, `italic`, `strikethrough`, `monospace`) call the
  pure `toggleMarker()` helper: if the selection (or the chars just outside it)
  is already wrapped, it unwraps; otherwise it wraps. Also available via
  `format(marker)` and the Cmd/Ctrl+B / Cmd/Ctrl+I shortcuts.
- **Line-prefix blocks** (`blockquote`, `bullet`, `ordered`) call
  `applyBlock(kind)` → the pure `toggleLinePrefix()` helper, which toggles the
  prefix across every line the selection spans (numbered lists renumber from 1).
- **Clear** (`clear`) calls `clearFormatting()` → the pure `stripFormatting()`
  helper, removing every inline marker and block prefix from the selection (or
  the whole message when nothing is selected).

Buttons `preventDefault` on `mousedown` so they don't steal the textarea
selection.

- Cmd/Ctrl + B → bold
- Cmd/Ctrl + I → italic

## Floating selection toolbar (bubble menu)

Add a `[data-c42-wa-floating]` element holding `[data-c42-wa-command]` buttons
anywhere inside the root (it can mirror a subset of the main toolbar). When the
selection is non-empty and the textarea is focused, the controller:

- reveals it (`data-state="open"`, `hidden` removed) and positions it **centered
  above the selection**, then hides it (`data-state="closed"`) on collapse, blur,
  Escape, scroll and window resize;
- reflects each inline marker's active state on **every** command button (both
  the main toolbar and the floating menu) via `aria-pressed="true|false"` and a
  toggled `data-active` attribute, so the theme can highlight the active format.

Because the floating buttons carry the same `data-c42-wa-command` values, they
are wired automatically — no extra configuration. The element is optional; omit
it to keep the plain editor.

### Bring your own, or use ours (`floating: true`)

Two ways to get the bubble menu, and they compose:

- **Author it** — drop a `[data-c42-wa-floating]` element in your markup. Full
  control over which buttons appear and their order. Always used when present.
- **`floating: true`** — if you *don't* author one, the controller injects a
  default themed menu (bold / italic / strikethrough / monospace) and removes it
  again on `destroy()`. Friendly default UI with zero markup.

The flag never overrides authored markup; it only fills the gap. In both cases
the controller owns show/hide/position and event wiring, so the behaviour is
identical — only the UI source differs.

```ts
new WhatsappEditor(root, { floating: true }); // inject the default bubble menu
```

#### Styling hooks

When you author your own menu, style it freely — the controller never sets
visual styles, only state attributes you can target:

- `[data-c42-wa-floating][data-state="open|closed"]` — visibility state.
- command buttons get `aria-pressed="true|false"` and a toggled `data-active`
  attribute when the selection is wrapped by that marker — hook these to
  highlight the active format (e.g. a green pill).

### Positioning

Textareas expose `selectionStart`/`selectionEnd` but no geometry, so the
controller measures the caret with the shared `getCaretCoordinates()` helper (a
hidden "mirror div" that replicates the textarea's text styling). It sets only
`left`/`top` in pixels relative to the (relatively positioned) root; the theme's
`transform: translate(-50%, calc(-100% - .5rem))` centers and lifts the bubble
above the line. Layout-agnostic by design — in jsdom the coordinates resolve to
`0` rather than throwing.

```html
<div data-c42-wa-floating class="c42-whatsapp-editor-floating" hidden>
  <button data-c42-wa-command="bold" aria-label="Bold"><b>B</b></button>
  <button data-c42-wa-command="italic" aria-label="Italic"><i>I</i></button>
  <button data-c42-wa-command="strikethrough" aria-label="Strikethrough">S</button>
  <span class="c42-whatsapp-editor-separator" aria-hidden="true"></span>
  <button data-c42-wa-command="monospace" aria-label="Monospace">&lt;/&gt;</button>
</div>
```

## Emoji picker (plugin)

Same contract as `textarea-emoji`: pass `renderPicker(container, insert)`. The
controller owns show/hide (reflected on `data-picker-open`, closes on
outside-click and Escape); you own the picker UI. `emojis: false` hides the
trigger entirely.

```ts
new WhatsappEditor(root, {
  renderPicker: (container, insert) => {
    // render your grid; call insert(emoji) on selection
  },
});
```

## Preview safety

`toPreviewHTML(text)` HTML-escapes the entire input *before* interpreting any
marker, and protects code spans so their contents are never re-parsed. It is
safe to assign the result to `innerHTML`.

## Events

```ts
editor.on('whatsappeditor:change', (e) => {
  e.detail.text;   // the markup string — send THIS to the API
  e.detail.html;   // preview HTML (escaped)
  e.detail.length; // character count
});
editor.on('whatsappeditor:format', (e) => {
  e.detail.marker; // 'bold' | 'italic' | 'strikethrough' | 'monospace'
});
```

## Using the pure helpers directly

The markup helpers are exported and DOM-free, so you can render previews
server-side or in tests without instantiating the controller:

```ts
import { toPreviewHTML, toggleMarker, WHATSAPP_MARKERS } from '@42/core/whatsapp-editor';

toPreviewHTML('*hi* _there_'); // '<strong>hi</strong> <em>there</em>'
toggleMarker('hello', 0, 5, WHATSAPP_MARKERS.bold); // { value: '*hello*', ... }
```

## API

Options: `value`, `maxLength` (default 4096), `emojis`, `floating`, `renderPicker`
Methods: `format(marker)`, `applyBlock(kind)`, `clearFormatting()`, `insertText(text)`, `getText()`, `getHTML()`, `focus()`, `isFloatingOpen()`, `value` get/set, `on(event, handler)`, `destroy()`
Events: `whatsappeditor:change` → `{ text, html, length }`, `whatsappeditor:format` → `{ marker, text }`
Pure helpers (DOM-free): `toPreviewHTML`, `toggleMarker`, `toggleLinePrefix`, `stripFormatting`, `isMarkerActive`, `WHATSAPP_MARKERS`
