Skip to content

Commit be3b924

Browse files
committed
docs(inlay): archi docs
1 parent 2d524fd commit be3b924

1 file changed

Lines changed: 351 additions & 0 deletions

File tree

src/inlay/ARCHITECTURE.md

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
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

Comments
 (0)