expo-pretext 0.2.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/android/src/main/java/expo/modules/pretext/ExpoPretextModule.kt +354 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoPretext.podspec +20 -0
- package/ios/ExpoPretext.swift +444 -0
- package/package.json +59 -0
- package/src/ExpoPretext.ts +70 -0
- package/src/__tests__/cache.test.ts +71 -0
- package/src/__tests__/font-utils.test.ts +69 -0
- package/src/__tests__/layout.test.ts +300 -0
- package/src/__tests__/obstacle-layout.test.ts +127 -0
- package/src/__tests__/setup-mocks.ts +14 -0
- package/src/analysis.ts +1208 -0
- package/src/bidi.ts +175 -0
- package/src/build.ts +503 -0
- package/src/cache.ts +59 -0
- package/src/engine-profile.ts +38 -0
- package/src/font-utils.ts +50 -0
- package/src/generated/bidi-data.ts +998 -0
- package/src/hooks/useFlashListHeights.ts +88 -0
- package/src/hooks/usePreparedText.ts +16 -0
- package/src/hooks/useTextHeight.ts +45 -0
- package/src/index.ts +56 -0
- package/src/layout.ts +353 -0
- package/src/line-break.ts +1113 -0
- package/src/obstacle-layout.ts +193 -0
- package/src/prepare.ts +246 -0
- package/src/rich-inline.ts +647 -0
- package/src/streaming.ts +61 -0
- package/src/types.ts +104 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
// Ported from chenglou/pretext src/inline-flow.ts
|
|
2
|
+
//
|
|
3
|
+
// Experimental sidecar for mixed inline runs under `white-space: normal`.
|
|
4
|
+
// It keeps the core layout API low-level while taking over the boring shared
|
|
5
|
+
// work that rich inline demos kept reimplementing in userland:
|
|
6
|
+
// - collapsed boundary whitespace across item boundaries
|
|
7
|
+
// - atomic inline boxes like pills
|
|
8
|
+
// - per-item extra horizontal chrome such as padding/borders
|
|
9
|
+
//
|
|
10
|
+
// Modifications from the original Pretext version:
|
|
11
|
+
// - InlineFlowItem.font (Canvas font shorthand) -> InlineFlowItem.style (TextStyle)
|
|
12
|
+
// - InlineFlowItem.break ('normal' | 'never') -> InlineFlowItem.atomic (boolean)
|
|
13
|
+
// atomic=true corresponds to break='never'
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
materializeLineRange,
|
|
17
|
+
measureNaturalWidth,
|
|
18
|
+
type LayoutCursor,
|
|
19
|
+
type LayoutLineRange,
|
|
20
|
+
type LayoutResult,
|
|
21
|
+
type PreparedTextWithSegments,
|
|
22
|
+
} from './layout'
|
|
23
|
+
import { prepareWithSegments } from './prepare'
|
|
24
|
+
import {
|
|
25
|
+
layoutNextLineRange as stepPreparedLineRangeRaw,
|
|
26
|
+
type LineBreakCursor,
|
|
27
|
+
stepPreparedLineGeometry as stepPreparedLineGeometryRaw,
|
|
28
|
+
} from './line-break'
|
|
29
|
+
|
|
30
|
+
import type { PreparedLineBreakData } from './line-break'
|
|
31
|
+
|
|
32
|
+
// The opaque PreparedTextWithSegments is structurally compatible with
|
|
33
|
+
// PreparedLineBreakData at runtime, but TypeScript's branded types hide that.
|
|
34
|
+
// Cast through the internal type instead of `any` so the boundary is typed.
|
|
35
|
+
const asPrepared = (p: PreparedTextWithSegments) => p as unknown as PreparedLineBreakData
|
|
36
|
+
const stepPreparedLineRange = (prepared: PreparedTextWithSegments, start: LineBreakCursor, maxWidth: number) =>
|
|
37
|
+
stepPreparedLineRangeRaw(asPrepared(prepared), start, maxWidth)
|
|
38
|
+
const stepPreparedLineGeometry = (prepared: PreparedTextWithSegments, start: LineBreakCursor, maxWidth: number) =>
|
|
39
|
+
stepPreparedLineGeometryRaw(asPrepared(prepared), start, maxWidth)
|
|
40
|
+
import type {
|
|
41
|
+
TextStyle,
|
|
42
|
+
InlineFlowItem,
|
|
43
|
+
PreparedInlineFlow,
|
|
44
|
+
InlineFlowCursor,
|
|
45
|
+
InlineFlowFragment,
|
|
46
|
+
InlineFlowLine,
|
|
47
|
+
} from './types'
|
|
48
|
+
|
|
49
|
+
export type { InlineFlowItem, PreparedInlineFlow, InlineFlowCursor, InlineFlowFragment, InlineFlowLine }
|
|
50
|
+
|
|
51
|
+
export type InlineFlowFragmentRange = {
|
|
52
|
+
itemIndex: number // Index into the original InlineFlowItem array
|
|
53
|
+
gapBefore: number // Collapsed inter-item gap paid before this fragment on this line
|
|
54
|
+
occupiedWidth: number // Text width plus the item's extraWidth contribution
|
|
55
|
+
start: LayoutCursor // Start cursor within the item's prepared text
|
|
56
|
+
end: LayoutCursor // End cursor within the item's prepared text
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type InlineFlowLineRange = {
|
|
60
|
+
fragments: InlineFlowFragmentRange[]
|
|
61
|
+
width: number
|
|
62
|
+
end: InlineFlowCursor
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type InlineFlowGeometry = {
|
|
66
|
+
lineCount: number
|
|
67
|
+
maxLineWidth: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type InternalPreparedInlineFlow = PreparedInlineFlow & {
|
|
71
|
+
items: PreparedInlineFlowItem[]
|
|
72
|
+
itemsBySourceItemIndex: Array<PreparedInlineFlowItem | undefined>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type PreparedInlineFlowItem = {
|
|
76
|
+
atomic: boolean
|
|
77
|
+
endGraphemeIndex: number
|
|
78
|
+
endSegmentIndex: number
|
|
79
|
+
extraWidth: number
|
|
80
|
+
gapBefore: number
|
|
81
|
+
naturalWidth: number
|
|
82
|
+
prepared: PreparedTextWithSegments
|
|
83
|
+
sourceItemIndex: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const COLLAPSIBLE_BOUNDARY_RE = /[ \t\n\f\r]+/
|
|
87
|
+
const LEADING_COLLAPSIBLE_BOUNDARY_RE = /^[ \t\n\f\r]+/
|
|
88
|
+
const TRAILING_COLLAPSIBLE_BOUNDARY_RE = /[ \t\n\f\r]+$/
|
|
89
|
+
const EMPTY_LAYOUT_CURSOR: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }
|
|
90
|
+
const FLOW_START_CURSOR: InlineFlowCursor = {
|
|
91
|
+
itemIndex: 0,
|
|
92
|
+
segmentIndex: 0,
|
|
93
|
+
graphemeIndex: 0,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getInternalPreparedInlineFlow(prepared: PreparedInlineFlow): InternalPreparedInlineFlow {
|
|
97
|
+
return prepared as InternalPreparedInlineFlow
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cloneCursor(cursor: LayoutCursor): LayoutCursor {
|
|
101
|
+
return {
|
|
102
|
+
segmentIndex: cursor.segmentIndex,
|
|
103
|
+
graphemeIndex: cursor.graphemeIndex,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isLineStartCursor(cursor: LayoutCursor): boolean {
|
|
108
|
+
return cursor.segmentIndex === 0 && cursor.graphemeIndex === 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getCollapsedSpaceWidth(style: TextStyle, cache: Map<string, number>): number {
|
|
112
|
+
// Cache key derived from the style properties that affect space width
|
|
113
|
+
const key = `${style.fontFamily}|${style.fontSize}|${style.fontWeight ?? '400'}|${style.fontStyle ?? 'normal'}`
|
|
114
|
+
const cached = cache.get(key)
|
|
115
|
+
if (cached !== undefined) return cached
|
|
116
|
+
|
|
117
|
+
const joinedWidth = measureNaturalWidth(prepareWithSegments('A A', style))
|
|
118
|
+
const compactWidth = measureNaturalWidth(prepareWithSegments('AA', style))
|
|
119
|
+
const collapsedWidth = Math.max(0, joinedWidth - compactWidth)
|
|
120
|
+
cache.set(key, collapsedWidth)
|
|
121
|
+
return collapsedWidth
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function prepareWholeItemLine(prepared: PreparedTextWithSegments): {
|
|
125
|
+
endGraphemeIndex: number
|
|
126
|
+
endSegmentIndex: number
|
|
127
|
+
width: number
|
|
128
|
+
} | null {
|
|
129
|
+
const line = stepPreparedLineRange(prepared, EMPTY_LAYOUT_CURSOR, Number.POSITIVE_INFINITY)
|
|
130
|
+
if (line === null) return null
|
|
131
|
+
return {
|
|
132
|
+
endGraphemeIndex: line.endGraphemeIndex,
|
|
133
|
+
endSegmentIndex: line.endSegmentIndex,
|
|
134
|
+
width: line.width,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type InlineFlowFragmentCollector = (
|
|
139
|
+
item: PreparedInlineFlowItem,
|
|
140
|
+
gapBefore: number,
|
|
141
|
+
occupiedWidth: number,
|
|
142
|
+
start: LayoutCursor,
|
|
143
|
+
end: LayoutCursor,
|
|
144
|
+
) => void
|
|
145
|
+
|
|
146
|
+
function endsInsideFirstSegment(segmentIndex: number, graphemeIndex: number): boolean {
|
|
147
|
+
return segmentIndex === 0 && graphemeIndex > 0
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function prepareInlineFlow(items: InlineFlowItem[]): PreparedInlineFlow {
|
|
151
|
+
const preparedItems: PreparedInlineFlowItem[] = []
|
|
152
|
+
const itemsBySourceItemIndex = Array.from<PreparedInlineFlowItem | undefined>({ length: items.length })
|
|
153
|
+
const collapsedSpaceWidthCache = new Map<string, number>()
|
|
154
|
+
let pendingGapWidth = 0
|
|
155
|
+
|
|
156
|
+
for (let index = 0; index < items.length; index++) {
|
|
157
|
+
const item = items[index]!
|
|
158
|
+
const hasLeadingWhitespace = LEADING_COLLAPSIBLE_BOUNDARY_RE.test(item.text)
|
|
159
|
+
const hasTrailingWhitespace = TRAILING_COLLAPSIBLE_BOUNDARY_RE.test(item.text)
|
|
160
|
+
const trimmedText = item.text
|
|
161
|
+
.replace(LEADING_COLLAPSIBLE_BOUNDARY_RE, '')
|
|
162
|
+
.replace(TRAILING_COLLAPSIBLE_BOUNDARY_RE, '')
|
|
163
|
+
|
|
164
|
+
if (trimmedText.length === 0) {
|
|
165
|
+
if (COLLAPSIBLE_BOUNDARY_RE.test(item.text) && pendingGapWidth === 0) {
|
|
166
|
+
pendingGapWidth = getCollapsedSpaceWidth(item.style, collapsedSpaceWidthCache)
|
|
167
|
+
}
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const gapBefore =
|
|
172
|
+
pendingGapWidth > 0
|
|
173
|
+
? pendingGapWidth
|
|
174
|
+
: hasLeadingWhitespace
|
|
175
|
+
? getCollapsedSpaceWidth(item.style, collapsedSpaceWidthCache)
|
|
176
|
+
: 0
|
|
177
|
+
const prepared = prepareWithSegments(trimmedText, item.style)
|
|
178
|
+
const wholeLine = prepareWholeItemLine(prepared)
|
|
179
|
+
if (wholeLine === null) {
|
|
180
|
+
pendingGapWidth = hasTrailingWhitespace ? getCollapsedSpaceWidth(item.style, collapsedSpaceWidthCache) : 0
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const preparedItem = {
|
|
185
|
+
atomic: item.atomic ?? false,
|
|
186
|
+
endGraphemeIndex: wholeLine.endGraphemeIndex,
|
|
187
|
+
endSegmentIndex: wholeLine.endSegmentIndex,
|
|
188
|
+
extraWidth: item.extraWidth ?? 0,
|
|
189
|
+
gapBefore,
|
|
190
|
+
naturalWidth: wholeLine.width,
|
|
191
|
+
prepared,
|
|
192
|
+
sourceItemIndex: index,
|
|
193
|
+
} satisfies PreparedInlineFlowItem
|
|
194
|
+
preparedItems.push(preparedItem)
|
|
195
|
+
itemsBySourceItemIndex[index] = preparedItem
|
|
196
|
+
|
|
197
|
+
pendingGapWidth = hasTrailingWhitespace ? getCollapsedSpaceWidth(item.style, collapsedSpaceWidthCache) : 0
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
items: preparedItems,
|
|
202
|
+
itemsBySourceItemIndex,
|
|
203
|
+
} as InternalPreparedInlineFlow
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function stepInlineFlowLine(
|
|
207
|
+
flow: InternalPreparedInlineFlow,
|
|
208
|
+
maxWidth: number,
|
|
209
|
+
cursor: InlineFlowCursor,
|
|
210
|
+
collectFragment?: InlineFlowFragmentCollector,
|
|
211
|
+
): number | null {
|
|
212
|
+
if (flow.items.length === 0 || cursor.itemIndex >= flow.items.length) return null
|
|
213
|
+
|
|
214
|
+
const safeWidth = Math.max(1, maxWidth)
|
|
215
|
+
let lineWidth = 0
|
|
216
|
+
let remainingWidth = safeWidth
|
|
217
|
+
let itemIndex = cursor.itemIndex
|
|
218
|
+
const textCursor: LineBreakCursor = {
|
|
219
|
+
segmentIndex: cursor.segmentIndex,
|
|
220
|
+
graphemeIndex: cursor.graphemeIndex,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
lineLoop:
|
|
224
|
+
while (itemIndex < flow.items.length) {
|
|
225
|
+
const item = flow.items[itemIndex]!
|
|
226
|
+
if (
|
|
227
|
+
!isLineStartCursor(textCursor) &&
|
|
228
|
+
textCursor.segmentIndex === item.endSegmentIndex &&
|
|
229
|
+
textCursor.graphemeIndex === item.endGraphemeIndex
|
|
230
|
+
) {
|
|
231
|
+
itemIndex++
|
|
232
|
+
textCursor.segmentIndex = 0
|
|
233
|
+
textCursor.graphemeIndex = 0
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const gapBefore = lineWidth === 0 ? 0 : item.gapBefore
|
|
238
|
+
const atItemStart = isLineStartCursor(textCursor)
|
|
239
|
+
|
|
240
|
+
if (item.atomic) {
|
|
241
|
+
if (!atItemStart) {
|
|
242
|
+
itemIndex++
|
|
243
|
+
textCursor.segmentIndex = 0
|
|
244
|
+
textCursor.graphemeIndex = 0
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const occupiedWidth = item.naturalWidth + item.extraWidth
|
|
249
|
+
const totalWidth = gapBefore + occupiedWidth
|
|
250
|
+
if (lineWidth > 0 && totalWidth > remainingWidth) break lineLoop
|
|
251
|
+
|
|
252
|
+
collectFragment?.(
|
|
253
|
+
item,
|
|
254
|
+
gapBefore,
|
|
255
|
+
occupiedWidth,
|
|
256
|
+
cloneCursor(EMPTY_LAYOUT_CURSOR),
|
|
257
|
+
{
|
|
258
|
+
segmentIndex: item.endSegmentIndex,
|
|
259
|
+
graphemeIndex: item.endGraphemeIndex,
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
lineWidth += totalWidth
|
|
263
|
+
remainingWidth = Math.max(0, safeWidth - lineWidth)
|
|
264
|
+
itemIndex++
|
|
265
|
+
textCursor.segmentIndex = 0
|
|
266
|
+
textCursor.graphemeIndex = 0
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const reservedWidth = gapBefore + item.extraWidth
|
|
271
|
+
if (lineWidth > 0 && reservedWidth >= remainingWidth) break lineLoop
|
|
272
|
+
|
|
273
|
+
if (atItemStart) {
|
|
274
|
+
const totalWidth = reservedWidth + item.naturalWidth
|
|
275
|
+
if (totalWidth <= remainingWidth) {
|
|
276
|
+
collectFragment?.(
|
|
277
|
+
item,
|
|
278
|
+
gapBefore,
|
|
279
|
+
item.naturalWidth + item.extraWidth,
|
|
280
|
+
cloneCursor(EMPTY_LAYOUT_CURSOR),
|
|
281
|
+
{
|
|
282
|
+
segmentIndex: item.endSegmentIndex,
|
|
283
|
+
graphemeIndex: item.endGraphemeIndex,
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
lineWidth += totalWidth
|
|
287
|
+
remainingWidth = Math.max(0, safeWidth - lineWidth)
|
|
288
|
+
itemIndex++
|
|
289
|
+
textCursor.segmentIndex = 0
|
|
290
|
+
textCursor.graphemeIndex = 0
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const availableWidth = Math.max(1, remainingWidth - reservedWidth)
|
|
296
|
+
const line = stepPreparedLineRange(item.prepared, textCursor, availableWidth)
|
|
297
|
+
if (line === null) {
|
|
298
|
+
itemIndex++
|
|
299
|
+
textCursor.segmentIndex = 0
|
|
300
|
+
textCursor.graphemeIndex = 0
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
if (
|
|
304
|
+
textCursor.segmentIndex === line.endSegmentIndex &&
|
|
305
|
+
textCursor.graphemeIndex === line.endGraphemeIndex
|
|
306
|
+
) {
|
|
307
|
+
itemIndex++
|
|
308
|
+
textCursor.segmentIndex = 0
|
|
309
|
+
textCursor.graphemeIndex = 0
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// If the only thing we can fit after paying the boundary gap is a partial
|
|
314
|
+
// slice of the item's first segment, prefer wrapping before the item so we
|
|
315
|
+
// keep whole-word-style boundaries when they exist. But once the current
|
|
316
|
+
// line can consume a real breakable unit from the item, stay greedy and
|
|
317
|
+
// keep filling the line.
|
|
318
|
+
if (
|
|
319
|
+
lineWidth > 0 &&
|
|
320
|
+
atItemStart &&
|
|
321
|
+
gapBefore > 0 &&
|
|
322
|
+
endsInsideFirstSegment(line.endSegmentIndex, line.endGraphemeIndex)
|
|
323
|
+
) {
|
|
324
|
+
const freshLine = stepPreparedLineRange(
|
|
325
|
+
item.prepared,
|
|
326
|
+
EMPTY_LAYOUT_CURSOR,
|
|
327
|
+
Math.max(1, safeWidth - item.extraWidth),
|
|
328
|
+
)
|
|
329
|
+
if (
|
|
330
|
+
freshLine !== null &&
|
|
331
|
+
(
|
|
332
|
+
freshLine.endSegmentIndex > line.endSegmentIndex ||
|
|
333
|
+
(
|
|
334
|
+
freshLine.endSegmentIndex === line.endSegmentIndex &&
|
|
335
|
+
freshLine.endGraphemeIndex > line.endGraphemeIndex
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
) {
|
|
339
|
+
break lineLoop
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
collectFragment?.(
|
|
344
|
+
item,
|
|
345
|
+
gapBefore,
|
|
346
|
+
line.width + item.extraWidth,
|
|
347
|
+
cloneCursor(textCursor),
|
|
348
|
+
{
|
|
349
|
+
segmentIndex: line.endSegmentIndex,
|
|
350
|
+
graphemeIndex: line.endGraphemeIndex,
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
lineWidth += gapBefore + line.width + item.extraWidth
|
|
354
|
+
remainingWidth = Math.max(0, safeWidth - lineWidth)
|
|
355
|
+
|
|
356
|
+
if (
|
|
357
|
+
line.endSegmentIndex === item.endSegmentIndex &&
|
|
358
|
+
line.endGraphemeIndex === item.endGraphemeIndex
|
|
359
|
+
) {
|
|
360
|
+
itemIndex++
|
|
361
|
+
textCursor.segmentIndex = 0
|
|
362
|
+
textCursor.graphemeIndex = 0
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
textCursor.segmentIndex = line.endSegmentIndex
|
|
367
|
+
textCursor.graphemeIndex = line.endGraphemeIndex
|
|
368
|
+
break
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (lineWidth === 0) return null
|
|
372
|
+
|
|
373
|
+
cursor.itemIndex = itemIndex
|
|
374
|
+
cursor.segmentIndex = textCursor.segmentIndex
|
|
375
|
+
cursor.graphemeIndex = textCursor.graphemeIndex
|
|
376
|
+
return lineWidth
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function stepInlineFlowLineGeometry(
|
|
380
|
+
flow: InternalPreparedInlineFlow,
|
|
381
|
+
maxWidth: number,
|
|
382
|
+
cursor: InlineFlowCursor,
|
|
383
|
+
): number | null {
|
|
384
|
+
if (flow.items.length === 0 || cursor.itemIndex >= flow.items.length) return null
|
|
385
|
+
|
|
386
|
+
const safeWidth = Math.max(1, maxWidth)
|
|
387
|
+
let lineWidth = 0
|
|
388
|
+
let remainingWidth = safeWidth
|
|
389
|
+
let itemIndex = cursor.itemIndex
|
|
390
|
+
|
|
391
|
+
lineLoop:
|
|
392
|
+
while (itemIndex < flow.items.length) {
|
|
393
|
+
const item = flow.items[itemIndex]!
|
|
394
|
+
if (
|
|
395
|
+
!isLineStartCursor(cursor) &&
|
|
396
|
+
cursor.segmentIndex === item.endSegmentIndex &&
|
|
397
|
+
cursor.graphemeIndex === item.endGraphemeIndex
|
|
398
|
+
) {
|
|
399
|
+
itemIndex++
|
|
400
|
+
cursor.segmentIndex = 0
|
|
401
|
+
cursor.graphemeIndex = 0
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const gapBefore = lineWidth === 0 ? 0 : item.gapBefore
|
|
406
|
+
const atItemStart = isLineStartCursor(cursor)
|
|
407
|
+
|
|
408
|
+
if (item.atomic) {
|
|
409
|
+
if (!atItemStart) {
|
|
410
|
+
itemIndex++
|
|
411
|
+
cursor.segmentIndex = 0
|
|
412
|
+
cursor.graphemeIndex = 0
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const occupiedWidth = item.naturalWidth + item.extraWidth
|
|
417
|
+
const totalWidth = gapBefore + occupiedWidth
|
|
418
|
+
if (lineWidth > 0 && totalWidth > remainingWidth) break lineLoop
|
|
419
|
+
|
|
420
|
+
lineWidth += totalWidth
|
|
421
|
+
remainingWidth = Math.max(0, safeWidth - lineWidth)
|
|
422
|
+
itemIndex++
|
|
423
|
+
cursor.segmentIndex = 0
|
|
424
|
+
cursor.graphemeIndex = 0
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const reservedWidth = gapBefore + item.extraWidth
|
|
429
|
+
if (lineWidth > 0 && reservedWidth >= remainingWidth) break lineLoop
|
|
430
|
+
|
|
431
|
+
if (atItemStart) {
|
|
432
|
+
const totalWidth = reservedWidth + item.naturalWidth
|
|
433
|
+
if (totalWidth <= remainingWidth) {
|
|
434
|
+
lineWidth += totalWidth
|
|
435
|
+
remainingWidth = Math.max(0, safeWidth - lineWidth)
|
|
436
|
+
itemIndex++
|
|
437
|
+
cursor.segmentIndex = 0
|
|
438
|
+
cursor.graphemeIndex = 0
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const availableWidth = Math.max(1, remainingWidth - reservedWidth)
|
|
444
|
+
const lineEnd: LineBreakCursor = {
|
|
445
|
+
segmentIndex: cursor.segmentIndex,
|
|
446
|
+
graphemeIndex: cursor.graphemeIndex,
|
|
447
|
+
}
|
|
448
|
+
const lineWidthForItem = stepPreparedLineGeometry(item.prepared, lineEnd, availableWidth)
|
|
449
|
+
if (lineWidthForItem === null) {
|
|
450
|
+
itemIndex++
|
|
451
|
+
cursor.segmentIndex = 0
|
|
452
|
+
cursor.graphemeIndex = 0
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
if (cursor.segmentIndex === lineEnd.segmentIndex && cursor.graphemeIndex === lineEnd.graphemeIndex) {
|
|
456
|
+
itemIndex++
|
|
457
|
+
cursor.segmentIndex = 0
|
|
458
|
+
cursor.graphemeIndex = 0
|
|
459
|
+
continue
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (
|
|
463
|
+
lineWidth > 0 &&
|
|
464
|
+
atItemStart &&
|
|
465
|
+
gapBefore > 0 &&
|
|
466
|
+
endsInsideFirstSegment(lineEnd.segmentIndex, lineEnd.graphemeIndex)
|
|
467
|
+
) {
|
|
468
|
+
const freshLineEnd: LineBreakCursor = {
|
|
469
|
+
segmentIndex: 0,
|
|
470
|
+
graphemeIndex: 0,
|
|
471
|
+
}
|
|
472
|
+
const freshLineWidth = stepPreparedLineGeometry(
|
|
473
|
+
item.prepared,
|
|
474
|
+
freshLineEnd,
|
|
475
|
+
Math.max(1, safeWidth - item.extraWidth),
|
|
476
|
+
)
|
|
477
|
+
if (
|
|
478
|
+
freshLineWidth !== null &&
|
|
479
|
+
(
|
|
480
|
+
freshLineEnd.segmentIndex > lineEnd.segmentIndex ||
|
|
481
|
+
(
|
|
482
|
+
freshLineEnd.segmentIndex === lineEnd.segmentIndex &&
|
|
483
|
+
freshLineEnd.graphemeIndex > lineEnd.graphemeIndex
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
) {
|
|
487
|
+
break lineLoop
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
lineWidth += gapBefore + lineWidthForItem + item.extraWidth
|
|
492
|
+
remainingWidth = Math.max(0, safeWidth - lineWidth)
|
|
493
|
+
|
|
494
|
+
if (lineEnd.segmentIndex === item.endSegmentIndex && lineEnd.graphemeIndex === item.endGraphemeIndex) {
|
|
495
|
+
itemIndex++
|
|
496
|
+
cursor.segmentIndex = 0
|
|
497
|
+
cursor.graphemeIndex = 0
|
|
498
|
+
continue
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
cursor.segmentIndex = lineEnd.segmentIndex
|
|
502
|
+
cursor.graphemeIndex = lineEnd.graphemeIndex
|
|
503
|
+
break
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (lineWidth === 0) return null
|
|
507
|
+
|
|
508
|
+
cursor.itemIndex = itemIndex
|
|
509
|
+
return lineWidth
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function layoutNextInlineFlowLineRange(
|
|
513
|
+
prepared: PreparedInlineFlow,
|
|
514
|
+
maxWidth: number,
|
|
515
|
+
start: InlineFlowCursor = FLOW_START_CURSOR,
|
|
516
|
+
): InlineFlowLineRange | null {
|
|
517
|
+
const flow = getInternalPreparedInlineFlow(prepared)
|
|
518
|
+
const end: InlineFlowCursor = {
|
|
519
|
+
itemIndex: start.itemIndex,
|
|
520
|
+
segmentIndex: start.segmentIndex,
|
|
521
|
+
graphemeIndex: start.graphemeIndex,
|
|
522
|
+
}
|
|
523
|
+
const fragments: InlineFlowFragmentRange[] = []
|
|
524
|
+
const width = stepInlineFlowLine(flow, maxWidth, end, (item, gapBefore, occupiedWidth, fragmentStart, fragmentEnd) => {
|
|
525
|
+
fragments.push({
|
|
526
|
+
itemIndex: item.sourceItemIndex,
|
|
527
|
+
gapBefore,
|
|
528
|
+
occupiedWidth,
|
|
529
|
+
start: fragmentStart,
|
|
530
|
+
end: fragmentEnd,
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
if (width === null) return null
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
fragments,
|
|
537
|
+
width,
|
|
538
|
+
end,
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function materializeFragmentText(
|
|
543
|
+
item: PreparedInlineFlowItem,
|
|
544
|
+
fragment: InlineFlowFragmentRange,
|
|
545
|
+
): string {
|
|
546
|
+
const line = materializeLineRange(item.prepared, {
|
|
547
|
+
width: fragment.occupiedWidth - item.extraWidth,
|
|
548
|
+
start: fragment.start,
|
|
549
|
+
end: fragment.end,
|
|
550
|
+
} satisfies LayoutLineRange)
|
|
551
|
+
return line.text
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function layoutNextInlineFlowLine(
|
|
555
|
+
prepared: PreparedInlineFlow,
|
|
556
|
+
maxWidth: number,
|
|
557
|
+
start: InlineFlowCursor = FLOW_START_CURSOR,
|
|
558
|
+
): InlineFlowLine | null {
|
|
559
|
+
const flow = getInternalPreparedInlineFlow(prepared)
|
|
560
|
+
const line = layoutNextInlineFlowLineRange(prepared, maxWidth, start)
|
|
561
|
+
if (line === null) return null
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
fragments: line.fragments.map(fragment => {
|
|
565
|
+
const item = flow.itemsBySourceItemIndex[fragment.itemIndex]
|
|
566
|
+
if (item === undefined) throw new Error('Missing inline-flow item for fragment')
|
|
567
|
+
return {
|
|
568
|
+
...fragment,
|
|
569
|
+
text: materializeFragmentText(item, fragment),
|
|
570
|
+
}
|
|
571
|
+
}),
|
|
572
|
+
width: line.width,
|
|
573
|
+
end: line.end,
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function walkInlineFlowLineRanges(
|
|
578
|
+
prepared: PreparedInlineFlow,
|
|
579
|
+
maxWidth: number,
|
|
580
|
+
onLine: (line: InlineFlowLineRange) => void,
|
|
581
|
+
): number {
|
|
582
|
+
let lineCount = 0
|
|
583
|
+
let cursor = FLOW_START_CURSOR
|
|
584
|
+
|
|
585
|
+
while (true) {
|
|
586
|
+
const line = layoutNextInlineFlowLineRange(prepared, maxWidth, cursor)
|
|
587
|
+
if (line === null) return lineCount
|
|
588
|
+
onLine(line)
|
|
589
|
+
lineCount++
|
|
590
|
+
cursor = line.end
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function measureInlineFlowGeometry(
|
|
595
|
+
prepared: PreparedInlineFlow,
|
|
596
|
+
maxWidth: number,
|
|
597
|
+
): InlineFlowGeometry {
|
|
598
|
+
const flow = getInternalPreparedInlineFlow(prepared)
|
|
599
|
+
let lineCount = 0
|
|
600
|
+
let maxLineWidth = 0
|
|
601
|
+
const cursor: InlineFlowCursor = {
|
|
602
|
+
itemIndex: 0,
|
|
603
|
+
segmentIndex: 0,
|
|
604
|
+
graphemeIndex: 0,
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
while (true) {
|
|
608
|
+
const lineWidth = stepInlineFlowLineGeometry(flow, maxWidth, cursor)
|
|
609
|
+
if (lineWidth === null) {
|
|
610
|
+
return {
|
|
611
|
+
lineCount,
|
|
612
|
+
maxLineWidth,
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
lineCount++
|
|
616
|
+
if (lineWidth > maxLineWidth) maxLineWidth = lineWidth
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function walkInlineFlowLines(
|
|
621
|
+
prepared: PreparedInlineFlow,
|
|
622
|
+
maxWidth: number,
|
|
623
|
+
onLine: (line: InlineFlowLine) => void,
|
|
624
|
+
): number {
|
|
625
|
+
let lineCount = 0
|
|
626
|
+
let cursor = FLOW_START_CURSOR
|
|
627
|
+
|
|
628
|
+
while (true) {
|
|
629
|
+
const line = layoutNextInlineFlowLine(prepared, maxWidth, cursor)
|
|
630
|
+
if (line === null) return lineCount
|
|
631
|
+
onLine(line)
|
|
632
|
+
lineCount++
|
|
633
|
+
cursor = line.end
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function measureInlineFlow(
|
|
638
|
+
prepared: PreparedInlineFlow,
|
|
639
|
+
maxWidth: number,
|
|
640
|
+
lineHeight: number,
|
|
641
|
+
): LayoutResult {
|
|
642
|
+
const { lineCount } = measureInlineFlowGeometry(prepared, maxWidth)
|
|
643
|
+
return {
|
|
644
|
+
lineCount,
|
|
645
|
+
height: lineCount * lineHeight,
|
|
646
|
+
}
|
|
647
|
+
}
|
package/src/streaming.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getNativeModule } from './ExpoPretext'
|
|
2
|
+
import { prepare } from './prepare'
|
|
3
|
+
import { textStyleToFontDescriptor, getFontKey } from './font-utils'
|
|
4
|
+
import { cacheNativeResult } from './cache'
|
|
5
|
+
import type { TextStyle, PreparedText, PrepareOptions } from './types'
|
|
6
|
+
|
|
7
|
+
type StreamingState = {
|
|
8
|
+
sourceText: string
|
|
9
|
+
prepared: PreparedText
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const streamingStates = new WeakMap<object, StreamingState>()
|
|
13
|
+
|
|
14
|
+
export function prepareStreaming(
|
|
15
|
+
key: object,
|
|
16
|
+
text: string,
|
|
17
|
+
style: TextStyle,
|
|
18
|
+
options?: PrepareOptions
|
|
19
|
+
): PreparedText {
|
|
20
|
+
if (!text) {
|
|
21
|
+
const prepared = prepare('', style, options)
|
|
22
|
+
streamingStates.delete(key)
|
|
23
|
+
return prepared
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const prev = streamingStates.get(key)
|
|
27
|
+
|
|
28
|
+
// No previous state or text is not an append → full prepare
|
|
29
|
+
if (!prev || !text.startsWith(prev.sourceText) || prev.sourceText === text) {
|
|
30
|
+
if (prev && prev.sourceText === text) {
|
|
31
|
+
return prev.prepared // same text, return cached
|
|
32
|
+
}
|
|
33
|
+
const prepared = prepare(text, style, options)
|
|
34
|
+
streamingStates.set(key, { sourceText: text, prepared })
|
|
35
|
+
return prepared
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Text grew — measure the new suffix to warm up the cache
|
|
39
|
+
const native = getNativeModule()
|
|
40
|
+
if (native) {
|
|
41
|
+
const newSuffix = text.slice(prev.sourceText.length)
|
|
42
|
+
if (newSuffix.length > 0) {
|
|
43
|
+
const font = textStyleToFontDescriptor(style)
|
|
44
|
+
const nativeOpts = options
|
|
45
|
+
? { whiteSpace: options.whiteSpace, locale: options.locale }
|
|
46
|
+
: undefined
|
|
47
|
+
const result = native.segmentAndMeasure(newSuffix, font, nativeOpts)
|
|
48
|
+
const fontKey = getFontKey(style)
|
|
49
|
+
cacheNativeResult(fontKey, result.segments, result.widths)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Full prepare with warmed cache — most segments will be JS cache hits
|
|
54
|
+
const prepared = prepare(text, style, options)
|
|
55
|
+
streamingStates.set(key, { sourceText: text, prepared })
|
|
56
|
+
return prepared
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function clearStreamingState(key: object): void {
|
|
60
|
+
streamingStates.delete(key)
|
|
61
|
+
}
|