|
| 1 | +# Inlay Architecture |
| 2 | + |
| 3 | +Inlay is a React-based rich text editor primitive built on `contentEditable`. It provides controlled text input with support for embedded tokens—inline elements that represent structured data (mentions, tags, links) while maintaining a clean string-based value model. |
| 4 | + |
| 5 | +## Core Concept: Token Divergence |
| 6 | + |
| 7 | +The key architectural decision is **token divergence**: a token's visual representation can differ from its raw value. |
| 8 | + |
| 9 | +``` |
| 10 | +Raw value: "Hello @alice_123, meet @bob_456" |
| 11 | +Visual DOM: "Hello Alice, meet Bob" |
| 12 | +``` |
| 13 | + |
| 14 | +This enables readable UI while preserving machine-readable identifiers in the underlying value. All cursor movement, selection, copy/paste, and deletion operations must account for this divergence. |
| 15 | + |
| 16 | +## Component Hierarchy |
| 17 | + |
| 18 | +``` |
| 19 | +StructuredInlay (high-level, plugin-based) |
| 20 | + └── Inlay.Root (core contentEditable wrapper) |
| 21 | + ├── Inlay.Token (inline token markers) |
| 22 | + └── Inlay.Portal (positioned overlays via Radix Popover) |
| 23 | +``` |
| 24 | + |
| 25 | +### Exports |
| 26 | + |
| 27 | +All components are exported under the `Inlay` namespace: |
| 28 | + |
| 29 | +- `Inlay.Root` — Main editor component, wraps contentEditable |
| 30 | +- `Inlay.Token` — Declares a token with `value` (raw) and `children` (visual) |
| 31 | +- `Inlay.Portal` — Positioned popover anchored to selection or editor |
| 32 | + - `Inlay.Portal.List` — Keyboard-navigable list container |
| 33 | + - `Inlay.Portal.Item` — Selectable item within a Inlay.Portal.List |
| 34 | +- `Inlay.StructuredInlay` — Higher-level component with plugin system |
| 35 | + |
| 36 | +Types are also exported: `InlayProps`, `InlayRef`, `TokenState`, `Plugin`, `Matcher`, `Match`. |
| 37 | + |
| 38 | +## Directory Structure |
| 39 | + |
| 40 | +``` |
| 41 | +src/inlay/ |
| 42 | +├── inlay.tsx # Core Root/Token/Portal components |
| 43 | +├── portal-list.tsx # Portal.List/Item compound components |
| 44 | +├── index.ts # Public exports |
| 45 | +├── hooks/ |
| 46 | +│ ├── use-clipboard.ts # Copy/cut/paste with token awareness |
| 47 | +│ ├── use-composition.ts # IME composition handling (incl. iOS quirks) |
| 48 | +│ ├── use-history.ts # Undo/redo stack |
| 49 | +│ ├── use-key-handlers.ts# Keyboard input processing (incl. Android GBoard) |
| 50 | +│ ├── use-placeholder-sync.ts |
| 51 | +│ ├── use-selection.ts # Selection state tracking (incl. iOS selectionchange) |
| 52 | +│ ├── use-selection-snap.ts # Cursor snapping to token boundaries |
| 53 | +│ ├── use-token-weaver.tsx # Two-pass token rendering |
| 54 | +│ ├── use-touch-selection.ts # Touch-based selection handling |
| 55 | +│ └── use-virtual-keyboard.ts # Virtual keyboard detection (visualViewport) |
| 56 | +├── internal/ |
| 57 | +│ ├── dom-utils.ts # DOM traversal, offset calculation |
| 58 | +│ └── string-utils.ts # Token matching/scanning |
| 59 | +├── structured/ |
| 60 | +│ ├── structured-inlay.tsx # Plugin-based wrapper |
| 61 | +│ └── plugins/ |
| 62 | +│ ├── plugin.ts # Plugin type definition |
| 63 | +│ └── mentions.tsx # Example mentions plugin |
| 64 | +├── __ct__/ # Playwright component tests |
| 65 | +└── __tests__/ # Vitest unit tests |
| 66 | +``` |
| 67 | + |
| 68 | +## Key Hooks |
| 69 | + |
| 70 | +### `useTokenWeaver` |
| 71 | +Two-pass rendering system: |
| 72 | +1. First pass: Children render invisibly to register tokens |
| 73 | +2. Second pass: Tokens are "weaved" into the text at correct positions |
| 74 | + |
| 75 | +This solves the chicken-and-egg problem of needing to know token positions before rendering while also needing to render to know what tokens exist. |
| 76 | + |
| 77 | +**Empty state:** When the value is empty, a zero-width space (`\u200B`) is rendered to maintain consistent caret height. Without this, the caret position can shift vertically when transitioning between empty and non-empty states (especially with styled tokens that have padding). |
| 78 | + |
| 79 | +### `useKeyHandlers` |
| 80 | +Intercepts all keyboard input via `onBeforeInput` and `onKeyDown`. Prevents default browser behavior and manually updates the controlled value. Handles: |
| 81 | +- Text insertion (with multi-char insert tracking for iOS swipe-text) |
| 82 | +- Backspace/Delete (with grapheme cluster awareness) |
| 83 | +- iOS swipe-text word deletion (deletes entire swiped word, preserves auto-inserted spaces) |
| 84 | +- Enter/Space |
| 85 | +- Undo/Redo (Ctrl+Z, Ctrl+Y) |
| 86 | + |
| 87 | +**iOS DOM sync:** On iOS, text insertions bypass `preventDefault()` to avoid multi-word suggestion bugs. The `input` event handler syncs DOM content to React state using `serializeRawFromDom()`. A `valueRef` provides synchronous access to the current value for decisions that must be made before React re-renders (e.g., detecting newlines to avoid `<br>` reconciliation crashes). |
| 88 | + |
| 89 | +### `useComposition` |
| 90 | +Manages IME (Input Method Editor) composition for CJK languages. Tracks composition state to avoid interfering with in-progress input. Handles composition commit via Space/Enter. |
| 91 | + |
| 92 | +### `useClipboard` |
| 93 | +Token-aware clipboard operations. When copying/cutting a token, extracts the raw value (not visual text). When pasting, inserts at correct raw offset position. |
| 94 | + |
| 95 | +### `useSelectionSnap` |
| 96 | +Snaps cursor and selection to token boundaries. Prevents cursor from landing inside a token's visual representation—it either sits before or after the token in raw-value terms. |
| 97 | + |
| 98 | +### `useHistory` |
| 99 | +Simple undo/redo with snapshot-based history. Coalesces rapid edits into single undo steps. |
| 100 | + |
| 101 | +### `useSelection` |
| 102 | +Tracks current selection as raw offsets. Provides `activeToken` when cursor is adjacent to or within a token. |
| 103 | + |
| 104 | +## Internal Utilities |
| 105 | + |
| 106 | +### `dom-utils.ts` |
| 107 | +Core DOM traversal functions that account for token divergence: |
| 108 | + |
| 109 | +- `getAbsoluteOffset(root, node, offset)` — Converts DOM selection position to raw string offset |
| 110 | +- `getTextNodeAtOffset(root, offset)` — Converts raw offset to DOM position |
| 111 | +- `setDomSelection(root, start, end?)` — Sets browser selection from raw offsets |
| 112 | +- `getClosestTokenEl(node)` — Finds containing token element |
| 113 | +- `getTokenRawRange(root, tokenEl)` — Gets raw offset range for a token |
| 114 | + |
| 115 | +### `string-utils.ts` |
| 116 | +Token matching and scanning: |
| 117 | + |
| 118 | +- `Matcher<T, N>` — Interface for token matchers (regex, prefix, custom) |
| 119 | +- `scan(text, matchers)` — Finds all token matches in a string, with overlap resolution |
| 120 | +- `Match<T, N>` — Represents a found token with position and parsed data |
| 121 | + |
| 122 | +**Overlap Resolution:** When multiple matchers produce overlapping matches, `scan()` uses a longest-match-wins strategy: |
| 123 | +1. Matches are sorted by start position, then by length (longest first) |
| 124 | +2. A greedy algorithm accepts non-overlapping matches, preferring longer ones |
| 125 | +3. When matches have the same range, the first matcher in the array wins |
| 126 | + |
| 127 | +This prevents duplicate tokens when plugins have overlapping patterns (e.g., `@alice` vs `@alice_vip`). |
| 128 | + |
| 129 | +## Portal Navigation |
| 130 | + |
| 131 | +Portal content often needs keyboard navigation (e.g., autocomplete lists). `Portal.List` and `Portal.Item` provide this with built-in keyboard handling. |
| 132 | + |
| 133 | +```tsx |
| 134 | +portal: ({ replace }) => ( |
| 135 | + <Inlay.Portal.List onSelect={(user) => replace(`@${user.id} `)}> |
| 136 | + {users.map(user => ( |
| 137 | + <Inlay.Portal.Item key={user.id} value={user}> |
| 138 | + {user.name} |
| 139 | + </Inlay.Portal.Item> |
| 140 | + ))} |
| 141 | + </Inlay.Portal.List> |
| 142 | +) |
| 143 | +``` |
| 144 | + |
| 145 | +**Keyboard behavior:** |
| 146 | +- `ArrowUp/Down` — Navigate items (wraps around) |
| 147 | +- `Enter` — Select active item |
| 148 | +- `Escape` — Dismiss portal |
| 149 | + |
| 150 | +**Virtual focus:** The editor retains DOM focus while Portal.List tracks the "active" item via state. This avoids contentEditable focus issues. |
| 151 | + |
| 152 | +**Styling:** Use `data-active` attribute for highlighting: |
| 153 | +```css |
| 154 | +[data-portal-item][data-active] { background: var(--highlight); } |
| 155 | +``` |
| 156 | + |
| 157 | +**Single-item pattern:** For confirmations or actions, use a single Inlay.Portal.Item: |
| 158 | +```tsx |
| 159 | +<Inlay.Portal.List onSelect={() => deleteToken()}> |
| 160 | + <Inlay.Portal.Item value="confirm">Delete? Press Enter to confirm.</Inlay.Portal.Item> |
| 161 | +</Inlay.Portal.List> |
| 162 | +``` |
| 163 | + |
| 164 | +**Positioning:** Portal uses manual DOM positioning instead of Radix's built-in anchor. This ensures the popover follows the caret on iOS Safari, where Radix's cached anchor position doesn't update correctly after text changes. The anchor rect is passed via `AnchorRectContext` and applied via `useLayoutEffect` on each render. |
| 165 | + |
| 166 | +## Plugin System (StructuredInlay) |
| 167 | + |
| 168 | +Plugins define token types with: |
| 169 | + |
| 170 | +```typescript |
| 171 | +type Plugin<P, T, N> = { |
| 172 | + props: P // Plugin configuration |
| 173 | + matcher: Matcher<T, N> // How to find tokens in text |
| 174 | + render: (ctx) => ReactNode // Token visual representation |
| 175 | + portal: (ctx) => ReactNode // Optional popover content |
| 176 | + onInsert: (value: T) => void |
| 177 | + onKeyDown: (event) => boolean |
| 178 | +} |
| 179 | +``` |
| 180 | +
|
| 181 | +Example: A mentions plugin matches `@username` patterns, renders styled chips, and shows a user card popover on focus. |
| 182 | +
|
| 183 | +## Browser Compatibility |
| 184 | +
|
| 185 | +- Handles Firefox's element-node selections (Ctrl+A sets selection on element, not text nodes) |
| 186 | +- WebKit composition quirks (extra `beforeInput` events after `compositionend`) |
| 187 | +- Cross-platform keyboard shortcuts via `ControlOrMeta` |
| 188 | +
|
| 189 | +## Mobile Support |
| 190 | +
|
| 191 | +Inlay provides full mobile device support with touch interactions, virtual keyboard handling, and platform-specific fixes. |
| 192 | +
|
| 193 | +### Mobile Input Attributes |
| 194 | +
|
| 195 | +The editor automatically sets mobile-friendly attributes: |
| 196 | +
|
| 197 | +```tsx |
| 198 | +<Inlay.Root |
| 199 | + inputMode="text" // Virtual keyboard hint |
| 200 | + autoCapitalize="sentences" // Mobile capitalization |
| 201 | + autoCorrect="off" // Disabled (tokens would break) |
| 202 | + enterKeyHint="done" // Mobile enter key label |
| 203 | +/> |
| 204 | +``` |
| 205 | +
|
| 206 | +All attributes are configurable via props: |
| 207 | +
|
| 208 | +```tsx |
| 209 | +type InlayProps = { |
| 210 | + inputMode?: 'text' | 'search' | 'email' | 'tel' | 'url' | 'numeric' | 'decimal' | 'none' |
| 211 | + autoCapitalize?: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters' |
| 212 | + autoCorrect?: 'on' | 'off' |
| 213 | + enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send' |
| 214 | + onVirtualKeyboardChange?: (open: boolean) => void |
| 215 | +} |
| 216 | +``` |
| 217 | +
|
| 218 | +### Touch Event Handling |
| 219 | +
|
| 220 | +The `useTouchSelection` hook handles touch-based interactions: |
| 221 | +
|
| 222 | +- **Tap to focus:** Positions caret at touch location |
| 223 | +- **Long press:** Triggers native selection mode |
| 224 | +- **Token snapping:** Touch inside tokens snaps to token boundaries |
| 225 | +- **Debouncing:** Prevents rapid touch event issues |
| 226 | +
|
| 227 | +### Virtual Keyboard Detection |
| 228 | +
|
| 229 | +The `useVirtualKeyboard` hook uses the `visualViewport` API to detect keyboard visibility: |
| 230 | +
|
| 231 | +```tsx |
| 232 | +<Inlay.Root |
| 233 | + onVirtualKeyboardChange={(open) => { |
| 234 | + console.log('Keyboard:', open ? 'open' : 'closed') |
| 235 | + }} |
| 236 | +/> |
| 237 | +``` |
| 238 | +
|
| 239 | +When the keyboard opens, the editor automatically scrolls into view. |
| 240 | +
|
| 241 | +### Portal Touch Navigation |
| 242 | +
|
| 243 | +`Portal.List` and `Portal.Item` support touch interactions: |
| 244 | +
|
| 245 | +- **Touch start:** Activates item (like hover on desktop) |
| 246 | +- **Touch end:** Selects item if touch didn't move (tap detection) |
| 247 | +- **Scroll vs tap:** Movement >10px cancels selection |
| 248 | +
|
| 249 | +```tsx |
| 250 | +// Portal items work the same on touch and desktop |
| 251 | +<Inlay.Portal.List onSelect={handleSelect}> |
| 252 | + <Inlay.Portal.Item value={item}> |
| 253 | + {item.label} |
| 254 | + </Inlay.Portal.Item> |
| 255 | +</Inlay.Portal.List> |
| 256 | +``` |
| 257 | +
|
| 258 | +### iOS-Specific Handling |
| 259 | +
|
| 260 | +- **Selection events:** iOS fires `selectionchange` on `document`, not the element. Added document-level listener in `useSelection`. |
| 261 | +- **Anchor rect updates:** iOS can return stale caret rects after text changes. The `useSelection` hook listens for `input` and `visualViewport` events, using `requestAnimationFrame` to read the rect after layout stabilizes. This ensures popovers follow the caret correctly. |
| 262 | +- **Composition data:** iOS Safari sometimes omits data in `compositionend`. Tracked via `compositionupdate` as fallback. |
| 263 | +- **iPad detection:** Includes modern iPads that report as "MacIntel" with touch. |
| 264 | +- **Multi-word suggestions prevention:** Calling `preventDefault()` on `beforeinput` for text insertions triggers iOS to show multi-word predictions (e.g., "I am going to the" as a single suggestion). However, iOS doesn't send usable event data for these predictions. By NOT calling `preventDefault()` on iOS for `insertText`, iOS shows only single-word suggestions which work correctly. The DOM is modified natively and synced to React state via the `input` event handler. |
| 265 | +- **Token context exception:** When the cursor is inside a token, we use the controlled path (with `preventDefault`) even on iOS. This avoids issues where `data-token-text` attributes become stale after edits, causing `serializeRawFromDom` to return incorrect values. |
| 266 | +- **Newline handling with React:** When content contains newlines, React renders `<br>` elements. If iOS modifies the DOM directly around `<br>` elements, React reconciliation fails with "NotFoundError". Solution: use a `valueRef` to detect newlines synchronously (before React re-renders) and call `preventDefault()` when newlines exist, handling the input via the controlled path. |
| 267 | +- **Swipe-text after newlines:** iOS sends swipe data with a leading space even at the start of a line (after `\n`). This space is stripped. iOS may also send the space as a SEPARATE event before the word—single-space insertions at line start are skipped entirely. |
| 268 | +- **Swipe-text word deletion:** When user swipe-types a word and presses backspace, iOS sends a single `deleteContentBackward` event with a targetRange covering only the last character. However, if we don't `preventDefault()`, iOS fires 5 rapid delete events and deletes the whole word natively. Since we need to `preventDefault()` to maintain controlled state, we track multi-char inserts and delete the entire chunk when backspace is pressed immediately after. |
| 269 | +- **Swipe-text space preservation:** iOS auto-inserts a leading space when swipe-typing after existing text (e.g., "hello" + swipe "world" → "hello world"). When deleting, only the word is removed, preserving the auto-inserted space. |
| 270 | +- **Autocomplete suggestions:** For `insertReplacementText` (autocomplete), iOS may not provide the replacement data when `preventDefault()` is called. On iOS, autocomplete is always handled via DOM sync regardless of newlines. |
| 271 | +- **Autocomplete state reset:** After pressing Enter, the `autocomplete` attribute is briefly toggled to reset iOS's autocomplete context. This prevents iOS from suggesting merged words like "helloworld" when the actual text is "hello\nworld". |
| 272 | +
|
| 273 | +### Android-Specific Handling |
| 274 | +
|
| 275 | +- **GBoard predictions:** Handles `insertReplacementText` input type for word predictions. Replacement text is in `event.data`. |
| 276 | +- **Delete variations:** Handles `deleteWordBackward`, `deleteWordForward`, `deleteSoftLineBackward`, `deleteSoftLineForward` input types. |
| 277 | +
|
| 278 | +### iOS Safari Text Suggestions |
| 279 | +
|
| 280 | +When a user taps a keyboard suggestion on iOS Safari: |
| 281 | +1. iOS fires `insertReplacementText` with `data: null` and the replacement text in `event.dataTransfer.getData('text/plain')` |
| 282 | +2. This differs from Android which puts the text in `event.data` |
| 283 | +3. The handler checks both `data` and `dataTransfer` to support both platforms |
| 284 | +
|
| 285 | +### Testing Mobile |
| 286 | +
|
| 287 | +Mobile tests use Playwright with device emulation: |
| 288 | +
|
| 289 | +```bash |
| 290 | +# Run mobile-specific tests |
| 291 | +bun run test:ct -- --project=mobile-chrome |
| 292 | +bun run test:ct -- --project=mobile-safari |
| 293 | +``` |
| 294 | +
|
| 295 | +Test files in `__ct__/inlay.mobile.spec.tsx` cover: |
| 296 | +- Touch-based caret positioning |
| 297 | +- Mobile attribute presence |
| 298 | +- Portal touch navigation |
| 299 | +- Token interaction on touch |
| 300 | +
|
| 301 | +## Accessibility |
| 302 | +
|
| 303 | +Inlay provides baseline accessibility out of the box: |
| 304 | +
|
| 305 | +- `role="textbox"` and `aria-multiline` are set automatically |
| 306 | +- Default `aria-label="Text input"` — consumers should override with context-specific labels |
| 307 | +- Placeholder is marked `aria-hidden="true"` to avoid duplicate announcements |
| 308 | +
|
| 309 | +**Automated a11y testing:** Uses `@axe-core/playwright` to catch WCAG violations in CI. Tests cover empty state, with-tokens, and focused states. |
| 310 | +
|
| 311 | +**Consumer responsibilities:** |
| 312 | +- Provide meaningful `aria-label` or `aria-labelledby` for the editor context |
| 313 | +- Ensure token visual styling meets contrast requirements |
| 314 | +- Test with actual screen readers (VoiceOver, NVDA) for announcement quality |
| 315 | +
|
| 316 | +## Testing |
| 317 | +
|
| 318 | +- **`__ct__/`** — Playwright component tests (real browser, keyboard simulation) |
| 319 | +- **`__tests__/`** — Vitest unit tests (JSDOM, faster iteration) |
| 320 | +
|
| 321 | +Run with: |
| 322 | +```bash |
| 323 | +bun run test:ct -- src/inlay/__ct__/ # Playwright |
| 324 | +bun run test -- src/inlay/ # Vitest |
| 325 | +``` |
| 326 | +
|
| 327 | +## Common Patterns |
| 328 | +
|
| 329 | +### Adding a new keyboard shortcut |
| 330 | +1. Add handler in `use-key-handlers.ts` `onKeyDown` |
| 331 | +2. Check for modifier keys, prevent default, update value |
| 332 | +3. Use `setDomSelection` to position cursor after state update |
| 333 | +
|
| 334 | +### Adding clipboard behavior |
| 335 | +1. Modify `use-clipboard.ts` |
| 336 | +2. Use `getSelectionFromDom` for token-aware selection |
| 337 | +3. Use `cfg.getValue()` to read raw value, `cfg.setValue()` to update |
| 338 | +
|
| 339 | +### Creating a new token type |
| 340 | +1. Define a `Matcher` in `string-utils.ts` format |
| 341 | +2. Use with `Inlay.StructuredInlay` plugins or manually with `<Inlay.Token value="...">` |
| 342 | +
|
| 343 | +## Known Limitations |
| 344 | +
|
| 345 | +- Single-line by default (`multiline` prop enables multi-line) |
| 346 | +- No rich formatting (bold, italic) — tokens only |
| 347 | +- No nested tokens |
| 348 | +- IME composition with tokens at boundaries can be tricky |
| 349 | +- Mobile autocorrect is disabled by default (would interfere with tokens) |
| 350 | +- Samsung keyboard may have composition quirks (test thoroughly) |
| 351 | +
|
0 commit comments