npm package: @luke86_1893/react-pretext
Description: React wrapper for @chenglou/pretext — fast text measurement and layout without DOM reflow
Latest version: 0.1.1

# react-pretext

React wrapper for [`@chenglou/pretext`](https://github.com/chenglou/pretext) — fast multiline text measurement and layout without DOM reflow.

`pretext` separates text measurement into two phases:
- `prepare(text, font)` — segments and measures text using an off-screen canvas (~expensive, cached)
- `layout(handle, width, lineHeight)` — pure arithmetic, sub-millisecond per call

`react-pretext` wraps these phases behind a hook and a component, adds automatic width tracking via `ResizeObserver`, reads font properties from `getComputedStyle` when not provided explicitly, and caches `prepare()` handles in a global LRU cache.

---

## Installation

```bash
npm install react-pretext @chenglou/pretext
```

**Peer dependencies:** `react >= 17`, `react-dom >= 17`, `@chenglou/pretext`

---

## Quick start

### Hook

```tsx
import { useTextLayout } from 'react-pretext'

function MyComponent() {
  const { ref, height, lineCount } = useTextLayout('Hello world', {
    width: 320,
    fontSize: 16,
    fontFamily: 'Inter',
  })

  return (
    <div ref={ref as React.RefObject<HTMLDivElement>} style={{ width: 320, fontSize: 16 }}>
      Hello world
      <span>height: {height}px, lines: {lineCount}</span>
    </div>
  )
}
```

### Component

```tsx
import { PreText } from 'react-pretext'

function MyComponent() {
  return (
    <PreText
      fontSize={16}
      fontFamily="Inter"
      onLayout={({ height, lineCount }) => console.log(height, lineCount)}
    >
      Hello world
    </PreText>
  )
}
```

---

## API

### `useTextLayout(text, options?)`

```ts
function useTextLayout(text: string, options?: UseTextLayoutOptions): TextLayoutResult
```

#### Options

| Prop | Type | Default | Description |
|---|---|---|---|
| `width` | `number` | — | Container width in px. If omitted, tracked via `ResizeObserver` on the returned `ref`. |
| `lineHeight` | `number` | `fontSize * 1.2` | Line height in px. |
| `withLines` | `boolean` | `false` | When `true`, populates `lines` via `layoutWithLines()`. |
| `fontSize` | `number` | from `getComputedStyle` | |
| `fontFamily` | `string` | from `getComputedStyle` | |
| `fontWeight` | `string \| number` | from `getComputedStyle` | |
| `fontStyle` | `string` | from `getComputedStyle` | |
| `cacheSize` | `number` | `500` | Override global LRU max size. See `configureCache()` for deterministic control. |

#### Returns

| Field | Type | Description |
|---|---|---|
| `ref` | `RefObject<HTMLElement>` | Attach to the container element. Used for auto-width and font fallback. |
| `height` | `number \| null` | `null` until first measurement (always `null` during SSR). |
| `lineCount` | `number \| null` | `null` until first measurement. |
| `lines` | `LineInfo[] \| null` | Populated only when `withLines: true`. |

---

### `<PreText>`

All `FontProps` fields are available as props, plus `width` and `lineHeight` from `UseTextLayoutOptions`, plus:

| Prop | Type | Default | Description |
|---|---|---|---|
| `children` | `string` | required | The text to measure and render. |
| `renderLines` | `boolean` | `false` | Render each line as an absolutely positioned `<span>`. **Client-only** — see SSR section. |
| `onLayout` | `(info: { height, lineCount, lines? }) => void` | — | Called after each measurement update. |
| `as` | `keyof JSX.IntrinsicElements` | `"div"` | HTML element to render as. |

All other HTML attributes are forwarded to the wrapper element.

---

### `configureCache(options)`

```ts
configureCache({ maxSize: 1000 })
```

Sets the global LRU cache capacity. Call once at app startup for deterministic behaviour. The default capacity is 500 entries. Entries are keyed on `text + fontString` and evicted least-recently-used.

---

### Types

```ts
type FontProps = {
  fontSize?: number
  fontFamily?: string
  fontWeight?: string | number
  fontStyle?: string
}

type LineInfo = {
  text: string
  width: number
  x: number
  y: number
}
```

---

## Auto font detection

When `FontProps` fields are omitted, `react-pretext` reads them from `getComputedStyle` on the element attached to `ref`. This means you can style the element via CSS and skip passing font props entirely:

```tsx
<PreText style={{ fontSize: '1rem', fontFamily: 'Inter' }} onLayout={console.log}>
  Hello world
</PreText>
```

If the element is not yet mounted (edge case on first render), the fallback is `"normal normal 16px sans-serif"`.

---

## SSR / Next.js

### Default mode (`renderLines={false}`) — SSR safe

`height` and `lineCount` are `null` on the server. The component renders its `children` inside the wrapper element without layout data. No crash, no hydration mismatch. After hydration, the hook activates and `onLayout` fires with real values.

### `renderLines={true}` — client-only

On the server there are no positioned `<span>` elements. After hydration, absolutely positioned spans appear. This causes a **React hydration mismatch warning**.

**Next.js App Router** — mark as client component:

```tsx
// components/MyText.tsx
'use client'
import { PreText } from 'react-pretext'

export function MyText({ children }: { children: string }) {
  return <PreText renderLines fontSize={16} fontFamily="Inter">{children}</PreText>
}
```

**Next.js Pages Router** — use dynamic import:

```tsx
import dynamic from 'next/dynamic'

const PreText = dynamic(
  () => import('react-pretext').then((m) => m.PreText),
  { ssr: false },
)
```

When `renderLines={true}` and height is not yet known (before the first client-side measurement), the wrapper has `height: 0` and spans are not visible. This resolves after the first layout cycle on the client.

---

## Demo

```bash
npm run demo
```

Opens a Vite dev server with five interactive examples:
1. Basic `<PreText>` with auto font detection
2. `renderLines` with custom line highlight
3. `useTextLayout` hook with a manual width slider
4. Live resize — drag the container edge to see layout update in real time
5. Cache behaviour — edit text to observe `prepare()` call counts

---

## License

MIT