@santjc/react-pretext 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,347 @@
1
+ # @santjc/react-pretext
2
+
3
+ Simple React wrapper over [`@chenglou/pretext`](https://www.npmjs.com/package/@chenglou/pretext) for deterministic text measurement before paint, without DOM reads.
4
+
5
+ `@santjc/react-pretext` is intentionally a small React layer over `@chenglou/pretext`. It lets you predict text height and line count from text, typography, and width before the browser renders the final layout. The core use case is measurement-driven UI: accordions, cards, virtualized lists, previews, and any responsive layout where text height affects placement.
6
+
7
+ The package keeps the original `pretext` model intact and adds React-facing hooks, types, and semantic rendering helpers where React actually helps.
8
+
9
+ The intended adoption path is:
10
+
11
+ - define typography once
12
+ - measure text with `useMeasuredText()`
13
+ - render semantic DOM with `PText`
14
+ - use predicted heights in normal UI like accordions, cards, lists, and previews
15
+ - opt into editorial flow only when you need custom line routing
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @santjc/react-pretext react react-dom
21
+ ```
22
+
23
+ Peer dependencies: React 18 or 19.
24
+
25
+ ## Why use it
26
+
27
+ - Predict text height before paint
28
+ - Avoid hidden measurement nodes and `scrollHeight` reads
29
+ - Truncate text to a known number of lines without DOM reads
30
+ - Keep measurement inputs and render styles aligned
31
+ - Re-layout from width changes without leaving the arithmetic path
32
+ - Drop down to lower-level hooks only when you need more control
33
+
34
+ ## Start Here
35
+
36
+ ### Measure text with one hook
37
+
38
+ ```tsx
39
+ import { createPretextTypography, useMeasuredText } from '@santjc/react-pretext'
40
+
41
+ function Example({ text }: { text: string }) {
42
+ const typography = createPretextTypography({
43
+ family: 'Inter, sans-serif',
44
+ size: 18,
45
+ weight: 400,
46
+ lineHeight: 28,
47
+ width: 320,
48
+ })
49
+
50
+ const { height, lineCount } = useMeasuredText({ text, typography })
51
+
52
+ return <div>{height}px / {lineCount} lines</div>
53
+ }
54
+ ```
55
+
56
+ Use this for the common case where a component needs a known text height, line count, or both.
57
+
58
+ ### Use shared typography with `PText`
59
+
60
+ ```tsx
61
+ import { PText, createPretextTypography } from '@santjc/react-pretext'
62
+
63
+ function Example() {
64
+ const body = createPretextTypography({
65
+ family: 'Inter, sans-serif',
66
+ size: 18,
67
+ weight: 400,
68
+ lineHeight: 28,
69
+ width: 320,
70
+ })
71
+
72
+ return (
73
+ <PText as="p" typography={body}>
74
+ Semantic text with one source of truth for measurement and render output.
75
+ </PText>
76
+ )
77
+ }
78
+ ```
79
+
80
+ `PText` is a semantic rendering helper for the shared typography object. It is useful when you want real DOM output to stay aligned with the same measurement inputs, but the main measurement story still starts with hooks.
81
+
82
+ ### Let `PText` observe responsive width
83
+
84
+ ```tsx
85
+ import { PText, createPretextTypography } from '@santjc/react-pretext'
86
+
87
+ function Example() {
88
+ const body = createPretextTypography({
89
+ family: 'Inter, sans-serif',
90
+ size: 18,
91
+ weight: 400,
92
+ lineHeight: 28,
93
+ })
94
+
95
+ return (
96
+ <div style={{ width: 'min(100%, 36rem)' }}>
97
+ <PText as="p" typography={body}>
98
+ This paragraph does not receive an explicit width. PText observes the element width and remeasures as the container changes.
99
+ </PText>
100
+ </div>
101
+ )
102
+ }
103
+ ```
104
+
105
+ ### Replace hidden measurement or `scrollHeight`
106
+
107
+ Before:
108
+
109
+ ```tsx
110
+ const ref = useRef<HTMLDivElement>(null)
111
+
112
+ useLayoutEffect(() => {
113
+ setHeight(ref.current?.scrollHeight ?? 0)
114
+ }, [text, width])
115
+ ```
116
+
117
+ After:
118
+
119
+ ```tsx
120
+ import { createPretextTypography, useMeasuredText, PText } from '@santjc/react-pretext'
121
+
122
+ function AccordionBody({ isOpen, text }: { isOpen: boolean; text: string }) {
123
+ const typography = createPretextTypography({
124
+ family: 'Inter, sans-serif',
125
+ size: 18,
126
+ weight: 400,
127
+ lineHeight: 28,
128
+ width: 360,
129
+ })
130
+
131
+ const { height } = useMeasuredText({ text, typography })
132
+
133
+ return (
134
+ <div style={{ height: isOpen ? `${height}px` : '0px', overflow: 'hidden' }}>
135
+ <PText as="p" typography={typography}>
136
+ {text}
137
+ </PText>
138
+ </div>
139
+ )
140
+ }
141
+ ```
142
+
143
+ ### Predict measured card or list heights
144
+
145
+ ```tsx
146
+ import { createPretextTypography, useMeasuredText } from '@santjc/react-pretext'
147
+
148
+ function ResultCard({ text, width }: { text: string; width: number }) {
149
+ const typography = createPretextTypography({
150
+ family: 'Inter, sans-serif',
151
+ size: 16,
152
+ weight: 400,
153
+ lineHeight: 26,
154
+ width: width - 32,
155
+ })
156
+
157
+ const { height, lineCount } = useMeasuredText({ text, typography })
158
+
159
+ return (
160
+ <div>
161
+ <div>{lineCount} lines</div>
162
+ <div>predicted body height: {height}px</div>
163
+ </div>
164
+ )
165
+ }
166
+ ```
167
+
168
+ This pattern works well for feeds, search results, CMS previews, issue lists, and any responsive grid where text height affects placement.
169
+
170
+ ### Truncate text for previews and teasers
171
+
172
+ ```tsx
173
+ import { PText, createPretextTypography, useTruncatedText } from '@santjc/react-pretext'
174
+
175
+ function ResultPreview({ text }: { text: string }) {
176
+ const typography = createPretextTypography({
177
+ family: 'Inter, sans-serif',
178
+ size: 16,
179
+ lineHeight: 24,
180
+ width: 280,
181
+ })
182
+
183
+ const preview = useTruncatedText({
184
+ text,
185
+ typography,
186
+ maxLines: 3,
187
+ })
188
+
189
+ return (
190
+ <>
191
+ <PText as="p" typography={typography}>{preview.text}</PText>
192
+ {preview.didTruncate ? <button>Read more</button> : null}
193
+ </>
194
+ )
195
+ }
196
+ ```
197
+
198
+ `useTruncatedText()` is meant for cards, snippets, collapsed previews, and compact list rows where the visible text itself needs to be deterministic before render.
199
+
200
+ ## Typography input
201
+
202
+ `createPretextTypography()` accepts either a CSS font shorthand string or a structured typography object.
203
+
204
+ Structured input:
205
+
206
+ ```tsx
207
+ const typography = createPretextTypography({
208
+ family: 'Inter, sans-serif',
209
+ size: 18,
210
+ weight: 400,
211
+ lineHeight: 28,
212
+ width: 320,
213
+ })
214
+ ```
215
+
216
+ Font shorthand input:
217
+
218
+ ```tsx
219
+ const typography = createPretextTypography({
220
+ font: '400 18px Inter, sans-serif',
221
+ lineHeight: 28,
222
+ width: 320,
223
+ })
224
+ ```
225
+
226
+ The structured form is the recommended default because it is easier to read, easier to derive from design tokens, and less fragile in application code. Internally, both forms resolve to the same `font` string and matching render styles.
227
+
228
+ ## Stable root API
229
+
230
+ The root package is the intentional React-facing surface.
231
+
232
+ - `createPretextTypography`
233
+ - `useElementWidth`
234
+ - `useMeasuredText`
235
+ - `usePreparedText`
236
+ - `usePreparedSegments`
237
+ - `usePretextLayout`
238
+ - `usePretextLines`
239
+ - `useTruncatedText`
240
+ - `PText`
241
+
242
+ Drop down to `usePreparedText()` and `usePretextLayout()` when you want to control the prepare and layout phases separately. Use `usePreparedSegments()` with `usePretextLines()` when you need actual line output.
243
+
244
+ ## Low-level pretext API
245
+
246
+ Raw `@chenglou/pretext` exports live on a dedicated `pretext` subpath:
247
+
248
+ ```ts
249
+ import {
250
+ prepare,
251
+ prepareWithSegments,
252
+ layout,
253
+ layoutWithLines,
254
+ layoutNextLine,
255
+ walkLineRanges,
256
+ } from '@santjc/react-pretext/pretext'
257
+ ```
258
+
259
+ Use this subpath when you want the original low-level pretext model without the React-first root entrypoint.
260
+
261
+ ## Editorial API
262
+
263
+ Editorial helpers live on the advanced `editorial` subpath:
264
+
265
+ ```ts
266
+ import {
267
+ FlowLines,
268
+ useTextFlow,
269
+ flowText,
270
+ carveLineSlots,
271
+ createLineSlotResolver,
272
+ getCircleBlockedLineRangeForRow,
273
+ pickWidestLineSlot,
274
+ EditorialColumns,
275
+ EditorialSurface,
276
+ type EditorialTrack,
277
+ type EditorialFigure,
278
+ } from '@santjc/react-pretext/editorial'
279
+ ```
280
+
281
+ These APIs are public and tested, but they are not part of the default adoption path. Reach for them when you need custom line rendering, obstacle-aware flow, or multi-column continuation.
282
+
283
+ ## SSR and runtime guidance
284
+
285
+ Measurement depends on canvas-backed text metrics, so the measurement hooks are a client-side feature.
286
+
287
+ - In Next.js and other SSR frameworks, call the hooks from client components.
288
+ - If you need a server-rendered fallback, render with a placeholder height and replace it after hydration.
289
+ - Keep measurement logic at the edge of the UI that actually needs it instead of pushing it into shared server code.
290
+
291
+ Example with a client component boundary:
292
+
293
+ ```tsx
294
+ 'use client'
295
+
296
+ import { createPretextTypography, useMeasuredText } from '@santjc/react-pretext'
297
+
298
+ export function MeasuredPreview({ text }: { text: string }) {
299
+ const typography = createPretextTypography({
300
+ family: 'Inter, sans-serif',
301
+ size: 16,
302
+ lineHeight: 24,
303
+ width: 320,
304
+ })
305
+
306
+ const { height } = useMeasuredText({ text, typography })
307
+
308
+ return <div style={{ minHeight: `${height}px` }}>{text}</div>
309
+ }
310
+ ```
311
+
312
+ ## Webfont guidance
313
+
314
+ Measurement is only as accurate as the font you actually render.
315
+
316
+ - Keep the measurement typography aligned with the real DOM font.
317
+ - Expect differences until a webfont finishes loading.
318
+ - If first-render accuracy matters, wait for the font before measuring or remeasure once the font is ready.
319
+
320
+ Example:
321
+
322
+ ```tsx
323
+ useEffect(() => {
324
+ document.fonts.ready.then(() => {
325
+ setFontsReady(true)
326
+ })
327
+ }, [])
328
+ ```
329
+
330
+ ## Caveats
331
+
332
+ - `createPretextTypography()` is the recommended way to keep measurement inputs and render styles aligned.
333
+ - `PText` currently supports `string` children only.
334
+ - `prepareOptions` currently map directly to pretext preparation options, such as `whiteSpace`.
335
+ - `useTextFlow` expects a reference-stable `getLineSlotAtY` callback. Memoize custom resolvers in React.
336
+ - Editorial `lineRenderMode="justify"` uses pretext-derived `word-spacing` and will skip justification for unsupported whitespace patterns instead of delegating wrapping back to the browser.
337
+ - `EditorialFigure` treats explicit `x` and `y` as overrides over `placement`, and clamps the result within available bounds.
338
+
339
+ ## Source layout
340
+
341
+ The repository keeps package boundaries visible in the source tree:
342
+
343
+ - `src/core/*` backs the root package exports
344
+ - `src/editorial/*` backs `@santjc/react-pretext/editorial`
345
+ - `src/test/*` holds cross-entrypoint package tests
346
+
347
+ Playground helpers stay in `apps/playground/src/lib/*` unless they are intentionally promoted into the package with matching tests and docs.
@@ -0,0 +1,51 @@
1
+ // src/hooks/useElementWidth.ts
2
+ import { useCallback, useEffect, useState } from "react";
3
+ function useElementWidth() {
4
+ const [node, setNode] = useState(null);
5
+ const [width, setWidth] = useState(0);
6
+ const ref = useCallback((nextNode) => {
7
+ setWidth(nextNode?.getBoundingClientRect().width ?? 0);
8
+ setNode(nextNode);
9
+ }, []);
10
+ useEffect(() => {
11
+ if (node === null) {
12
+ return;
13
+ }
14
+ const observer = new ResizeObserver((entries) => {
15
+ const entry = entries[0];
16
+ if (entry === void 0) {
17
+ return;
18
+ }
19
+ setWidth((currentWidth) => currentWidth === entry.contentRect.width ? currentWidth : entry.contentRect.width);
20
+ });
21
+ observer.observe(node);
22
+ return () => {
23
+ observer.disconnect();
24
+ };
25
+ }, [node]);
26
+ return { ref, width, node };
27
+ }
28
+
29
+ // src/hooks/usePreparedSegments.ts
30
+ import { prepareWithSegments } from "@chenglou/pretext";
31
+ import { useMemo } from "react";
32
+ function usePreparedSegments({ text, font, options, enabled = true }) {
33
+ const whiteSpace = options?.whiteSpace;
34
+ return useMemo(() => {
35
+ if (!enabled || text.length === 0 || font.length === 0) {
36
+ return {
37
+ prepared: null,
38
+ isReady: false
39
+ };
40
+ }
41
+ return {
42
+ prepared: prepareWithSegments(text, font, whiteSpace === void 0 ? void 0 : { whiteSpace }),
43
+ isReady: true
44
+ };
45
+ }, [enabled, font, text, whiteSpace]);
46
+ }
47
+
48
+ export {
49
+ useElementWidth,
50
+ usePreparedSegments
51
+ };
@@ -0,0 +1,51 @@
1
+ // src/core/hooks/useElementWidth.ts
2
+ import { useCallback, useEffect, useState } from "react";
3
+ function useElementWidth() {
4
+ const [node, setNode] = useState(null);
5
+ const [width, setWidth] = useState(0);
6
+ const ref = useCallback((nextNode) => {
7
+ setWidth(nextNode?.getBoundingClientRect().width ?? 0);
8
+ setNode(nextNode);
9
+ }, []);
10
+ useEffect(() => {
11
+ if (node === null) {
12
+ return;
13
+ }
14
+ const observer = new ResizeObserver((entries) => {
15
+ const entry = entries[0];
16
+ if (entry === void 0) {
17
+ return;
18
+ }
19
+ setWidth((currentWidth) => currentWidth === entry.contentRect.width ? currentWidth : entry.contentRect.width);
20
+ });
21
+ observer.observe(node);
22
+ return () => {
23
+ observer.disconnect();
24
+ };
25
+ }, [node]);
26
+ return { ref, width, node };
27
+ }
28
+
29
+ // src/core/hooks/usePreparedSegments.ts
30
+ import { prepareWithSegments } from "@chenglou/pretext";
31
+ import { useMemo } from "react";
32
+ function usePreparedSegments({ text, font, options, enabled = true }) {
33
+ const whiteSpace = options?.whiteSpace;
34
+ return useMemo(() => {
35
+ if (!enabled || text.length === 0 || font.length === 0) {
36
+ return {
37
+ prepared: null,
38
+ isReady: false
39
+ };
40
+ }
41
+ return {
42
+ prepared: prepareWithSegments(text, font, whiteSpace === void 0 ? void 0 : { whiteSpace }),
43
+ isReady: true
44
+ };
45
+ }, [enabled, font, text, whiteSpace]);
46
+ }
47
+
48
+ export {
49
+ useElementWidth,
50
+ usePreparedSegments
51
+ };
@@ -0,0 +1,163 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { CSSProperties, ReactNode } from 'react';
3
+ import { P as PrepareOptions } from './usePreparedText-DmUr2kss.js';
4
+ import { LayoutCursor, PreparedTextWithSegments } from '@chenglou/pretext';
5
+
6
+ type LineSlot = {
7
+ left: number;
8
+ right: number;
9
+ };
10
+ type BlockedLineRange = {
11
+ left: number;
12
+ right: number;
13
+ };
14
+ type GetBlockedLineRanges = (lineTop: number, lineBottom: number) => BlockedLineRange[];
15
+ type LineSlotResolver = (y: number) => LineSlot | null;
16
+ type CreateLineSlotResolverInput = {
17
+ baseLineSlot: LineSlot;
18
+ lineHeight: number;
19
+ minWidth?: number;
20
+ getBlockedLineRanges?: GetBlockedLineRanges;
21
+ };
22
+ type CircleLineRangeInput = {
23
+ cx: number;
24
+ cy: number;
25
+ radius: number;
26
+ lineTop: number;
27
+ lineBottom: number;
28
+ horizontalPadding?: number;
29
+ verticalPadding?: number;
30
+ };
31
+ declare function carveLineSlots(baseLineSlot: LineSlot, blockedLineRanges: BlockedLineRange[], minWidth?: number): LineSlot[];
32
+ declare function getCircleBlockedLineRangeForRow({ cx, cy, radius, lineTop, lineBottom, horizontalPadding, verticalPadding, }: CircleLineRangeInput): BlockedLineRange | null;
33
+ declare function pickWidestLineSlot(lineSlots: LineSlot[]): LineSlot | null;
34
+ declare function createLineSlotResolver({ baseLineSlot, lineHeight, minWidth, getBlockedLineRanges, }: CreateLineSlotResolverInput): LineSlotResolver;
35
+
36
+ type EditorialPlacement = 'top-left' | 'top-center' | 'top-right' | 'center-left' | 'center' | 'center-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
37
+ type EditorialFigure = {
38
+ shape: 'circle' | 'rect';
39
+ width: number;
40
+ height: number;
41
+ placement?: EditorialPlacement;
42
+ x?: number;
43
+ y?: number;
44
+ linePadding?: number;
45
+ className?: string;
46
+ style?: CSSProperties;
47
+ content?: ReactNode;
48
+ };
49
+
50
+ type EditorialTrack = {
51
+ width?: number;
52
+ fr?: number;
53
+ minHeight?: number;
54
+ paddingInline?: number;
55
+ paddingBlock?: number;
56
+ className?: string;
57
+ style?: CSSProperties;
58
+ figures?: EditorialFigure[];
59
+ };
60
+
61
+ type EditorialColumnsProps = {
62
+ text: string;
63
+ font: string;
64
+ lineHeight: number;
65
+ gap?: number;
66
+ lineRenderMode?: 'natural' | 'justify';
67
+ prepareOptions?: PrepareOptions;
68
+ className?: string;
69
+ style?: CSSProperties;
70
+ tracks: EditorialTrack[];
71
+ };
72
+ declare function EditorialColumns({ text, font, lineHeight, gap, lineRenderMode, prepareOptions, className, style, tracks: trackDefs, }: EditorialColumnsProps): react_jsx_runtime.JSX.Element;
73
+
74
+ type EditorialSurfaceProps = {
75
+ text: string;
76
+ font: string;
77
+ lineHeight: number;
78
+ startY?: number;
79
+ maxY?: number;
80
+ minHeight?: number;
81
+ lineRenderMode?: 'natural' | 'justify';
82
+ prepareOptions?: PrepareOptions;
83
+ className?: string;
84
+ style?: CSSProperties;
85
+ figures?: EditorialFigure[];
86
+ };
87
+ declare function EditorialSurface({ text, font, lineHeight, startY, maxY, minHeight, lineRenderMode, prepareOptions, className, style, figures, }: EditorialSurfaceProps): react_jsx_runtime.JSX.Element;
88
+
89
+ type PositionedLine = {
90
+ text: string;
91
+ x: number;
92
+ y: number;
93
+ width: number;
94
+ slotLeft: number;
95
+ slotRight: number;
96
+ slotWidth: number;
97
+ start: LayoutCursor;
98
+ end: LayoutCursor;
99
+ };
100
+ type TextFlowInput = {
101
+ prepared: PreparedTextWithSegments;
102
+ lineHeight: number;
103
+ getLineSlotAtY: (y: number) => LineSlot | null;
104
+ startY?: number;
105
+ startCursor?: LayoutCursor;
106
+ maxLines?: number;
107
+ maxY?: number;
108
+ maxSteps?: number;
109
+ };
110
+ type TextFlowResult = {
111
+ lines: PositionedLine[];
112
+ height: number;
113
+ exhausted: boolean;
114
+ truncated: boolean;
115
+ endCursor: LayoutCursor;
116
+ };
117
+ declare function flowText({ prepared, lineHeight, getLineSlotAtY, startY, startCursor, maxLines, maxY, maxSteps, }: TextFlowInput): TextFlowResult;
118
+
119
+ type EditorialPositionedLine = PositionedLine & {
120
+ justifyWordSpacing: number | null;
121
+ isTerminal: boolean;
122
+ isParagraphTerminal: boolean;
123
+ };
124
+
125
+ type FlowRenderableLine = PositionedLine & Partial<Pick<EditorialPositionedLine, 'justifyWordSpacing'>>;
126
+ type FlowLineRenderInput<TLine extends FlowRenderableLine = FlowRenderableLine> = {
127
+ key: string;
128
+ line: TLine;
129
+ text: string;
130
+ style: CSSProperties;
131
+ };
132
+ type FlowLinesProps<TLine extends FlowRenderableLine = FlowRenderableLine> = {
133
+ lines: TLine[];
134
+ font: string;
135
+ lineHeight: number;
136
+ lineClassName?: string;
137
+ lineRenderMode?: 'natural' | 'justify';
138
+ renderLine?: (input: FlowLineRenderInput<TLine>) => ReactNode;
139
+ };
140
+ declare function FlowLines<TLine extends FlowRenderableLine = FlowRenderableLine>({ lines, font, lineHeight, lineClassName, lineRenderMode, renderLine, }: FlowLinesProps<TLine>): react_jsx_runtime.JSX.Element;
141
+
142
+ type UseTextFlowInput = {
143
+ prepared: PreparedTextWithSegments | null;
144
+ lineHeight: number;
145
+ getLineSlotAtY: (y: number) => LineSlot | null;
146
+ startY?: number;
147
+ startCursor?: LayoutCursor;
148
+ maxLines?: number;
149
+ maxY?: number;
150
+ maxSteps?: number;
151
+ enabled?: boolean;
152
+ };
153
+ type UseTextFlowResult = {
154
+ lines: PositionedLine[];
155
+ height: number;
156
+ exhausted: boolean;
157
+ truncated: boolean;
158
+ isReady: boolean;
159
+ endCursor: LayoutCursor;
160
+ };
161
+ declare function useTextFlow({ prepared, lineHeight, getLineSlotAtY, startY, startCursor, maxLines, maxY, maxSteps, enabled, }: UseTextFlowInput): UseTextFlowResult;
162
+
163
+ export { EditorialColumns, type EditorialColumnsProps, type EditorialFigure, type EditorialPlacement, EditorialSurface, type EditorialSurfaceProps, type EditorialTrack, type FlowLineRenderInput, FlowLines, type FlowLinesProps, type FlowRenderableLine, type LineSlot, type PositionedLine, type TextFlowInput, type TextFlowResult, type UseTextFlowInput, type UseTextFlowResult, carveLineSlots, createLineSlotResolver, flowText, getCircleBlockedLineRangeForRow, pickWidestLineSlot, useTextFlow };