@octane-xplat/image 0.0.1 → 0.11.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/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/native/Image.tsrx.js +264 -0
- package/dist/native/cache.js +64 -0
- package/dist/native/hash-image.js +285 -0
- package/dist/native/image-binding.js +103 -0
- package/dist/native/image-content.js +207 -0
- package/dist/native/index.js +3 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/native/prefetch-batch.js +27 -0
- package/dist/web/Image.web.tsrx.js +123 -0
- package/dist/web/cache.web.js +35 -0
- package/dist/web/hash-image.js +285 -0
- package/dist/web/image-content.js +134 -0
- package/dist/web/index.js +3 -0
- package/dist/web/octane-client-build.json +8 -0
- package/dist/web/prefetch-batch.js +27 -0
- package/package.json +84 -4
- package/src/Image.macos.tsrx +24 -0
- package/src/Image.tsrx +269 -0
- package/src/Image.web.tsrx +176 -0
- package/src/cache.macos.ts +31 -0
- package/src/cache.ts +93 -0
- package/src/cache.web.ts +49 -0
- package/src/hash-image.ts +313 -0
- package/src/image-binding.ts +170 -0
- package/src/image-content.ts +310 -0
- package/src/index.macos.ts +26 -0
- package/src/index.ts +28 -0
- package/src/index.web.ts +28 -0
- package/src/native-elements.d.ts +8 -0
- package/src/prefetch-batch.ts +50 -0
- package/src/prefetch.web.test.ts +105 -0
- package/src/props.ts +119 -0
- package/types/generated/.tsrx-typegen-manifest.json +16 -0
- package/types/generated/Image.d.ts +16 -0
- package/types/generated/Image.web.d.ts +12 -0
- package/types/generated/cache.d.ts +28 -0
- package/types/generated/cache.web.d.ts +16 -0
- package/types/generated/hash-image.d.ts +14 -0
- package/types/generated/image-binding.d.ts +40 -0
- package/types/generated/image-content.d.ts +57 -0
- package/types/generated/index.d.ts +3 -0
- package/types/generated/index.web.d.ts +3 -0
- package/types/generated/prefetch-batch.d.ts +10 -0
- package/types/generated/props.d.ts +99 -0
- package/types/index.macos.d.ts +28 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ImageContentFit,
|
|
3
|
+
ImageContentPosition,
|
|
4
|
+
ImageContentPositionObject,
|
|
5
|
+
ImageContentPositionValue,
|
|
6
|
+
ImageSourceLike,
|
|
7
|
+
} from './props'
|
|
8
|
+
|
|
9
|
+
/** VENDORED from packages/ui/src/image-content.ts — keep in sync.
|
|
10
|
+
* Shared `contentFit`/`contentPosition` + multi-source math for `Image` —
|
|
11
|
+
* pure functions used by every leaf (docs/notes/expo-image-study.md L7/L8).
|
|
12
|
+
* Semantics mirror CSS `object-fit`/`object-position`, which is also
|
|
13
|
+
* expo-image's contract: the leaves compute the drawn-content rect
|
|
14
|
+
* themselves instead of delegating to platform scaleType/contentMode,
|
|
15
|
+
* so all platforms get the same answer.
|
|
16
|
+
*
|
|
17
|
+
* The default fit is 'cover' everywhere (the expo/RN default). Today's
|
|
18
|
+
* platform defaults disagree — web <img> is 'fill' and NS stretch is
|
|
19
|
+
* 'aspectFit' — so an un-propped Image was silently divergent. */
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_CONTENT_FIT: ImageContentFit = 'cover'
|
|
22
|
+
|
|
23
|
+
export function resolveContentFit(fit?: ImageContentFit): ImageContentFit {
|
|
24
|
+
return fit ?? DEFAULT_CONTENT_FIT
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** expo-image's keyword shorthands; the object form positions relative to
|
|
28
|
+
* any corner pair ({top,left}, {top,right}, {bottom,left}, {bottom,right}). */
|
|
29
|
+
const POSITION_KEYWORDS: Record<string, ImageContentPositionObject> = {
|
|
30
|
+
center: { top: '50%', left: '50%' },
|
|
31
|
+
top: { top: 0, left: '50%' },
|
|
32
|
+
right: { top: '50%', right: 0 },
|
|
33
|
+
bottom: { bottom: 0, left: '50%' },
|
|
34
|
+
left: { top: '50%', left: 0 },
|
|
35
|
+
'top center': { top: 0, left: '50%' },
|
|
36
|
+
'top right': { top: 0, right: 0 },
|
|
37
|
+
'top left': { top: 0, left: 0 },
|
|
38
|
+
'right center': { top: '50%', right: 0 },
|
|
39
|
+
'right top': { top: 0, right: 0 },
|
|
40
|
+
'right bottom': { bottom: 0, right: 0 },
|
|
41
|
+
'bottom center': { bottom: 0, left: '50%' },
|
|
42
|
+
'bottom right': { bottom: 0, right: 0 },
|
|
43
|
+
'bottom left': { bottom: 0, left: 0 },
|
|
44
|
+
'left center': { top: '50%', left: 0 },
|
|
45
|
+
'left top': { top: 0, left: 0 },
|
|
46
|
+
'left bottom': { bottom: 0, left: 0 },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveContentPosition(
|
|
50
|
+
position?: ImageContentPosition,
|
|
51
|
+
): ImageContentPositionObject {
|
|
52
|
+
if (typeof position === 'string') {
|
|
53
|
+
return POSITION_KEYWORDS[position] ?? POSITION_KEYWORDS.center
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return position ?? POSITION_KEYWORDS.center
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type AxisValue = { percent: number } | { dips: number }
|
|
60
|
+
|
|
61
|
+
function parseAxisValue(value: ImageContentPositionValue | undefined): AxisValue | null {
|
|
62
|
+
if (value == null) {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof value === 'number') {
|
|
67
|
+
return { dips: value }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const text = String(value).trim()
|
|
71
|
+
if (text === 'center') {
|
|
72
|
+
return { percent: 50 }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (text.endsWith('%')) {
|
|
76
|
+
const parsed = Number.parseFloat(text)
|
|
77
|
+
return Number.isNaN(parsed) ? null : { percent: parsed }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const parsed = Number.parseFloat(text)
|
|
81
|
+
return Number.isNaN(parsed) ? null : { dips: parsed }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Offset of the drawn content's start edge inside the view along one axis.
|
|
85
|
+
* expo-image `calcTranslation`: percentages run over the free space
|
|
86
|
+
* (view − content), absolute dips measure from the named edge, and the
|
|
87
|
+
* reverse edge (right/bottom) counts back from the far side. */
|
|
88
|
+
export function contentAxisOffset(
|
|
89
|
+
viewSize: number,
|
|
90
|
+
contentSize: number,
|
|
91
|
+
start: ImageContentPositionValue | undefined,
|
|
92
|
+
end: ImageContentPositionValue | undefined,
|
|
93
|
+
): number {
|
|
94
|
+
const from = parseAxisValue(start)
|
|
95
|
+
if (from) {
|
|
96
|
+
return 'percent' in from ? (from.percent / 100) * (viewSize - contentSize) : from.dips
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const to = parseAxisValue(end)
|
|
100
|
+
if (to) {
|
|
101
|
+
return 'percent' in to
|
|
102
|
+
? (1 - to.percent / 100) * (viewSize - contentSize)
|
|
103
|
+
: viewSize - contentSize - to.dips
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (viewSize - contentSize) / 2
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function centeredAxis(
|
|
110
|
+
start: ImageContentPositionValue | undefined,
|
|
111
|
+
end: ImageContentPositionValue | undefined,
|
|
112
|
+
): boolean {
|
|
113
|
+
const from = parseAxisValue(start)
|
|
114
|
+
if (from) {
|
|
115
|
+
return 'percent' in from && from.percent === 50
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const to = parseAxisValue(end)
|
|
119
|
+
if (to) {
|
|
120
|
+
return 'percent' in to && to.percent === 50
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isCenteredContentPosition(position: ImageContentPositionObject): boolean {
|
|
127
|
+
return (
|
|
128
|
+
centeredAxis(position.left, position.right) && centeredAxis(position.top, position.bottom)
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The rect the bitmap occupies inside the view box, in the same units the
|
|
133
|
+
* caller measures with (native: dips; web: CSS px via object-fit anyway —
|
|
134
|
+
* only the native leaf calls this). Every length unit is dips except the
|
|
135
|
+
* intrinsic size, which the leaf must supply in dips too. */
|
|
136
|
+
export function contentRect(
|
|
137
|
+
fit: ImageContentFit,
|
|
138
|
+
position: ImageContentPositionObject,
|
|
139
|
+
viewWidth: number,
|
|
140
|
+
viewHeight: number,
|
|
141
|
+
imageWidth: number,
|
|
142
|
+
imageHeight: number,
|
|
143
|
+
): { x: number; y: number; width: number; height: number } {
|
|
144
|
+
let width: number
|
|
145
|
+
let height: number
|
|
146
|
+
switch (fit) {
|
|
147
|
+
case 'fill':
|
|
148
|
+
width = viewWidth
|
|
149
|
+
height = viewHeight
|
|
150
|
+
break
|
|
151
|
+
case 'cover': {
|
|
152
|
+
const scale = Math.max(viewWidth / imageWidth, viewHeight / imageHeight)
|
|
153
|
+
width = imageWidth * scale
|
|
154
|
+
height = imageHeight * scale
|
|
155
|
+
break
|
|
156
|
+
}
|
|
157
|
+
case 'scale-down': {
|
|
158
|
+
const scale = Math.min(1, Math.min(viewWidth / imageWidth, viewHeight / imageHeight))
|
|
159
|
+
width = imageWidth * scale
|
|
160
|
+
height = imageHeight * scale
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
case 'none':
|
|
164
|
+
width = imageWidth
|
|
165
|
+
height = imageHeight
|
|
166
|
+
break
|
|
167
|
+
default: {
|
|
168
|
+
// 'contain'
|
|
169
|
+
const scale = Math.min(viewWidth / imageWidth, viewHeight / imageHeight)
|
|
170
|
+
width = imageWidth * scale
|
|
171
|
+
height = imageHeight * scale
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
x: contentAxisOffset(viewWidth, width, position.left, position.right),
|
|
177
|
+
y: contentAxisOffset(viewHeight, height, position.top, position.bottom),
|
|
178
|
+
width,
|
|
179
|
+
height,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** True when no platform stretch/scaleType can express the request and the
|
|
184
|
+
* leaf must measure + lay out the image itself: 'none' (iOS maps NS
|
|
185
|
+
* stretch='none' to TopLeft, not CSS centered), 'scale-down' (needs the
|
|
186
|
+
* intrinsic-vs-view compare), or any non-center position. 'fill' is
|
|
187
|
+
* exempt — its rect is always the whole box, so position can't matter. */
|
|
188
|
+
export function needsContentRect(
|
|
189
|
+
fit: ImageContentFit,
|
|
190
|
+
position: ImageContentPositionObject,
|
|
191
|
+
): boolean {
|
|
192
|
+
if (fit === 'none' || fit === 'scale-down') {
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return fit !== 'fill' && !isCenteredContentPosition(position)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** NS `stretch` for the fast path — only reached for fits the platform
|
|
200
|
+
* vocabulary expresses identically (contain/cover/fill centered). */
|
|
201
|
+
export function contentFitToStretch(fit: ImageContentFit): 'aspectFit' | 'aspectFill' | 'fill' {
|
|
202
|
+
switch (fit) {
|
|
203
|
+
case 'contain':
|
|
204
|
+
return 'aspectFit'
|
|
205
|
+
case 'fill':
|
|
206
|
+
return 'fill'
|
|
207
|
+
default:
|
|
208
|
+
return 'aspectFill'
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** L8 — expo `getBestSource`: the source whose declared pixel count
|
|
213
|
+
* (`width·height·scale²`, dips × density) is closest to the view's pixel
|
|
214
|
+
* count. Needs a real measured size; one source or no size picks nothing
|
|
215
|
+
* to decide. Unsized sources score worst and are skipped when any sized
|
|
216
|
+
* candidate exists. */
|
|
217
|
+
export function bestImageSource(
|
|
218
|
+
sources: readonly ImageSourceLike[],
|
|
219
|
+
viewWidth: number,
|
|
220
|
+
viewHeight: number,
|
|
221
|
+
screenScale: number,
|
|
222
|
+
): ImageSourceLike | null {
|
|
223
|
+
if (sources.length === 0 || viewWidth <= 0 || viewHeight <= 0) {
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (sources.length === 1) {
|
|
228
|
+
return sources[0]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const target = viewWidth * viewHeight * screenScale * screenScale
|
|
232
|
+
let best: ImageSourceLike | null = null
|
|
233
|
+
let bestFit = Infinity
|
|
234
|
+
for (const source of sources) {
|
|
235
|
+
const pixelCount = (source.width ?? 0) * (source.height ?? 0) * (source.scale ?? 1) ** 2
|
|
236
|
+
const fit = Math.abs(1 - pixelCount / target)
|
|
237
|
+
if (fit < bestFit) {
|
|
238
|
+
best = source
|
|
239
|
+
bestFit = fit
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return best
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function ensureWebUnits(value: ImageContentPositionValue): string {
|
|
247
|
+
const text = String(value).trim()
|
|
248
|
+
return text.endsWith('%') ? text : `${text}px`
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Browser `object-position` for a resolved position — mirrors expo-image
|
|
252
|
+
* web's `getObjectPositionFromContentPositionObject`. */
|
|
253
|
+
export function objectPositionCSSValue(position: ImageContentPositionObject): string {
|
|
254
|
+
const resolved: ImageContentPositionObject = { ...position }
|
|
255
|
+
if (resolved.top == null && resolved.bottom == null) {
|
|
256
|
+
resolved.top = '50%'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (resolved.left == null && resolved.right == null) {
|
|
260
|
+
resolved.left = '50%'
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
(['top', 'bottom', 'left', 'right'] as const)
|
|
265
|
+
.map((edge) => (resolved[edge] != null ? `${edge} ${ensureWebUnits(resolved[edge]!)}` : ''))
|
|
266
|
+
.filter((part) => part !== '')
|
|
267
|
+
.join(' ') || '50% 50%'
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Browser `srcset` candidates for a source array: `w` descriptors (real
|
|
272
|
+
* file px = width·scale) when every source is sized, `x` descriptors when
|
|
273
|
+
* every source only carries density. `src` falls back to the largest
|
|
274
|
+
* declared pixel count — guaranteed coverage where srcset is ignored. */
|
|
275
|
+
export function imageSrcSet(sources: readonly ImageSourceLike[]): {
|
|
276
|
+
src: string
|
|
277
|
+
srcSet?: string
|
|
278
|
+
} {
|
|
279
|
+
if (sources.length === 0) {
|
|
280
|
+
return { src: '' }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const everyWidth = sources.every(
|
|
284
|
+
(source) => typeof source.width === 'number' && source.width > 0,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
const everyScale =
|
|
288
|
+
!everyWidth &&
|
|
289
|
+
sources.every((source) => typeof source.scale === 'number' && source.scale > 0)
|
|
290
|
+
|
|
291
|
+
const srcSet = everyWidth
|
|
292
|
+
? sources
|
|
293
|
+
.map((source) => `${source.uri} ${Math.round(source.width! * (source.scale ?? 1))}w`)
|
|
294
|
+
.join(', ')
|
|
295
|
+
: everyScale
|
|
296
|
+
? sources.map((source) => `${source.uri} ${source.scale}x`).join(', ')
|
|
297
|
+
: undefined
|
|
298
|
+
|
|
299
|
+
let fallback = sources[0]
|
|
300
|
+
let bestCount = -Infinity
|
|
301
|
+
for (const source of sources) {
|
|
302
|
+
const count = (source.width ?? 0) * (source.height ?? 0) * (source.scale ?? 1) ** 2
|
|
303
|
+
if (count > bestCount) {
|
|
304
|
+
bestCount = count
|
|
305
|
+
fallback = source
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { src: fallback.uri, srcSet }
|
|
310
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export { Image } from './Image.macos.tsrx'
|
|
2
|
+
export {
|
|
3
|
+
clearImageCaches,
|
|
4
|
+
evictImage,
|
|
5
|
+
initializeImageCache,
|
|
6
|
+
isImageCached,
|
|
7
|
+
prefetch,
|
|
8
|
+
} from './cache.macos'
|
|
9
|
+
|
|
10
|
+
export type {
|
|
11
|
+
ImageCacheConfig,
|
|
12
|
+
ImageCachePolicy,
|
|
13
|
+
ImageCacheQueryOptions,
|
|
14
|
+
ImageCacheState,
|
|
15
|
+
ImageContentFit,
|
|
16
|
+
ImageContentPosition,
|
|
17
|
+
ImageContentPositionObject,
|
|
18
|
+
ImageContentPositionString,
|
|
19
|
+
ImageContentPositionValue,
|
|
20
|
+
ImageDecoding,
|
|
21
|
+
ImageErrorEvent,
|
|
22
|
+
ImageLoadEvent,
|
|
23
|
+
ImageProps,
|
|
24
|
+
PrefetchOptions,
|
|
25
|
+
ImageSourceLike,
|
|
26
|
+
} from './props'
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Platform barrels — explicit .tsrx leaf imports (moduleSuffixes don't
|
|
2
|
+
// reach .tsrx; same convention as @octane-xplat/ui index.*.ts).
|
|
3
|
+
export { Image } from './Image.tsrx'
|
|
4
|
+
export {
|
|
5
|
+
clearImageCaches,
|
|
6
|
+
evictImage,
|
|
7
|
+
initializeImageCache,
|
|
8
|
+
isImageCached,
|
|
9
|
+
prefetch,
|
|
10
|
+
} from './cache'
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
ImageCacheConfig,
|
|
14
|
+
ImageCachePolicy,
|
|
15
|
+
ImageCacheQueryOptions,
|
|
16
|
+
ImageCacheState,
|
|
17
|
+
ImageContentFit,
|
|
18
|
+
ImageContentPosition,
|
|
19
|
+
ImageContentPositionObject,
|
|
20
|
+
ImageContentPositionString,
|
|
21
|
+
ImageContentPositionValue,
|
|
22
|
+
ImageDecoding,
|
|
23
|
+
ImageErrorEvent,
|
|
24
|
+
ImageLoadEvent,
|
|
25
|
+
ImageProps,
|
|
26
|
+
PrefetchOptions,
|
|
27
|
+
ImageSourceLike,
|
|
28
|
+
} from './props'
|
package/src/index.web.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Platform barrels — explicit .tsrx leaf imports (moduleSuffixes don't
|
|
2
|
+
// reach .tsrx; same convention as @octane-xplat/ui index.*.ts).
|
|
3
|
+
export { Image } from './Image.web.tsrx'
|
|
4
|
+
export {
|
|
5
|
+
clearImageCaches,
|
|
6
|
+
evictImage,
|
|
7
|
+
initializeImageCache,
|
|
8
|
+
isImageCached,
|
|
9
|
+
prefetch,
|
|
10
|
+
} from './cache.web'
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
ImageCacheConfig,
|
|
14
|
+
ImageCachePolicy,
|
|
15
|
+
ImageCacheQueryOptions,
|
|
16
|
+
ImageCacheState,
|
|
17
|
+
ImageContentFit,
|
|
18
|
+
ImageContentPosition,
|
|
19
|
+
ImageContentPositionObject,
|
|
20
|
+
ImageContentPositionString,
|
|
21
|
+
ImageContentPositionValue,
|
|
22
|
+
ImageDecoding,
|
|
23
|
+
ImageErrorEvent,
|
|
24
|
+
ImageLoadEvent,
|
|
25
|
+
ImageProps,
|
|
26
|
+
PrefetchOptions,
|
|
27
|
+
ImageSourceLike,
|
|
28
|
+
} from './props'
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Img } from '@nativescript-community/ui-image'
|
|
2
|
+
import type { Attributes } from '@nativescript-community/octane/intrinsics'
|
|
3
|
+
|
|
4
|
+
declare module '@nativescript-community/octane/intrinsics' {
|
|
5
|
+
interface NativeScriptElements {
|
|
6
|
+
cachedimage: Attributes<typeof Img>
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** VENDORED — identical copy in packages/gif/src/prefetch-batch.ts; keep in
|
|
2
|
+
* sync.
|
|
3
|
+
*
|
|
4
|
+
* Shared batch driver for the image-engine leaves' `prefetch` contract:
|
|
5
|
+
* normalizes `string | string[]`, caps in-flight requests JS-side, and
|
|
6
|
+
* resolves `true` only when every URL warms — `false` on the first failure
|
|
7
|
+
* (in-flight requests settle on their own; the result is already decided). */
|
|
8
|
+
|
|
9
|
+
/** Matches the `maxRequests = 5` ceiling NS core's ImageCache used. */
|
|
10
|
+
export const DEFAULT_PREFETCH_CONCURRENCY = 5
|
|
11
|
+
|
|
12
|
+
export function prefetchBatch(
|
|
13
|
+
srcs: string | string[],
|
|
14
|
+
concurrency: number | undefined,
|
|
15
|
+
warm: (url: string) => Promise<boolean>,
|
|
16
|
+
): Promise<boolean> {
|
|
17
|
+
const urls = Array.isArray(srcs) ? srcs : [srcs]
|
|
18
|
+
if (urls.length === 0) {
|
|
19
|
+
return Promise.resolve(true)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const requested = Math.floor(concurrency ?? DEFAULT_PREFETCH_CONCURRENCY)
|
|
23
|
+
const limit =
|
|
24
|
+
Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_PREFETCH_CONCURRENCY
|
|
25
|
+
|
|
26
|
+
return new Promise<boolean>((resolve) => {
|
|
27
|
+
let next = 0
|
|
28
|
+
let active = Math.min(limit, urls.length)
|
|
29
|
+
for (let i = 0; i < active; i++) {
|
|
30
|
+
void (async () => {
|
|
31
|
+
while (next < urls.length) {
|
|
32
|
+
const url = urls[next++]
|
|
33
|
+
try {
|
|
34
|
+
if (!(await warm(url))) {
|
|
35
|
+
resolve(false)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
resolve(false)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (--active === 0) {
|
|
45
|
+
resolve(true)
|
|
46
|
+
}
|
|
47
|
+
})()
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { prefetch } from './cache.web'
|
|
3
|
+
import { prefetchBatch } from './prefetch-batch'
|
|
4
|
+
|
|
5
|
+
// Web prefetch contract: a throwaway <img> per URL, the promise awaited until
|
|
6
|
+
// load/error fires, false when any URL fails, and a JS-side concurrency cap —
|
|
7
|
+
// the same contract @octane-xplat/gif's prefetch carries.
|
|
8
|
+
|
|
9
|
+
class MockImage {
|
|
10
|
+
static instances: MockImage[] = []
|
|
11
|
+
onload: (() => void) | null = null
|
|
12
|
+
onerror: (() => void) | null = null
|
|
13
|
+
set src(_url: string) {
|
|
14
|
+
MockImage.instances.push(this)
|
|
15
|
+
}
|
|
16
|
+
load() {
|
|
17
|
+
this.onload?.()
|
|
18
|
+
}
|
|
19
|
+
fail() {
|
|
20
|
+
this.onerror?.()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
25
|
+
|
|
26
|
+
async function drainPending(promise: Promise<boolean>) {
|
|
27
|
+
let result: boolean | undefined
|
|
28
|
+
void promise.then((value) => {
|
|
29
|
+
result = value
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
while (result === undefined) {
|
|
33
|
+
for (const image of MockImage.instances.splice(0)) {
|
|
34
|
+
image.load()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await flush()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('prefetch (web)', () => {
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
MockImage.instances = []
|
|
46
|
+
vi.unstubAllGlobals()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('resolves true only after every <img> fires load', async () => {
|
|
50
|
+
vi.stubGlobal('Image', MockImage)
|
|
51
|
+
const promise = prefetch(['https://x/a.png', 'https://x/b.png'])
|
|
52
|
+
let settled = false
|
|
53
|
+
void promise.then(() => {
|
|
54
|
+
settled = true
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
await flush()
|
|
58
|
+
expect(settled).toBe(false) // bytes have not landed yet — honest await
|
|
59
|
+
expect(await drainPending(promise)).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('resolves false when any URL fails', async () => {
|
|
63
|
+
vi.stubGlobal('Image', MockImage)
|
|
64
|
+
const promise = prefetch(['https://x/a.png', 'https://x/b.png'])
|
|
65
|
+
const [failed] = MockImage.instances.splice(0)
|
|
66
|
+
failed.fail()
|
|
67
|
+
expect(await promise).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('accepts a single URL and resolves true for an empty batch', async () => {
|
|
71
|
+
vi.stubGlobal('Image', MockImage)
|
|
72
|
+
expect(await prefetch([])).toBe(true)
|
|
73
|
+
const promise = prefetch('https://x/a.png')
|
|
74
|
+
expect(MockImage.instances).toHaveLength(1)
|
|
75
|
+
expect(await drainPending(promise)).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('caps in-flight loads at options.concurrency', async () => {
|
|
79
|
+
vi.stubGlobal('Image', MockImage)
|
|
80
|
+
const promise = prefetch(
|
|
81
|
+
Array.from({ length: 7 }, (_, i) => `https://x/${i}.png`),
|
|
82
|
+
{ concurrency: 2, headers: { Authorization: 'Bearer x' } },
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(MockImage.instances).toHaveLength(2)
|
|
86
|
+
MockImage.instances.splice(0).forEach((image) => image.load())
|
|
87
|
+
await flush()
|
|
88
|
+
await flush()
|
|
89
|
+
expect(MockImage.instances.length).toBeLessThanOrEqual(2)
|
|
90
|
+
expect(await drainPending(promise)).toBe(true)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('defaults to five parallel loads', async () => {
|
|
94
|
+
vi.stubGlobal('Image', MockImage)
|
|
95
|
+
const promise = prefetch(Array.from({ length: 9 }, (_, i) => `https://x/${i}.png`))
|
|
96
|
+
expect(MockImage.instances).toHaveLength(5)
|
|
97
|
+
expect(await drainPending(promise)).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('prefetchBatch', () => {
|
|
102
|
+
it('reports false if the warm callback throws', async () => {
|
|
103
|
+
expect(await prefetchBatch(['a', 'b'], 5, () => Promise.reject(new Error('boom')))).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
})
|
package/src/props.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ImageProps as CoreImageProps } from '@octane-xplat/ui';
|
|
2
|
+
|
|
3
|
+
// The leaf's Image is a drop-in for `@octane-xplat/ui`'s Image — same name,
|
|
4
|
+
// same props. The core contract types come straight from ui so the leaf can
|
|
5
|
+
// never drift from them; swapping engines is an import-path change.
|
|
6
|
+
export type {
|
|
7
|
+
ImageContentFit,
|
|
8
|
+
ImageContentPosition,
|
|
9
|
+
ImageContentPositionObject,
|
|
10
|
+
ImageContentPositionString,
|
|
11
|
+
ImageContentPositionValue,
|
|
12
|
+
ImageDecoding,
|
|
13
|
+
ImageSourceLike,
|
|
14
|
+
} from '@octane-xplat/ui';
|
|
15
|
+
|
|
16
|
+
/** How the engine's caches participate in a load. The plugin exposes a single
|
|
17
|
+
* all-off switch (`noCache` → Glide `skipMemoryCache` + `DiskCacheStrategy.NONE`
|
|
18
|
+
* on Android, `SDWebImageOptions.FromLoaderOnly` on iOS), so the only honest
|
|
19
|
+
* non-default value is 'none'. 'memory'/'disk' splits are not representable
|
|
20
|
+
* through the plugin's request options today. Ignored on web — browsers own
|
|
21
|
+
* their HTTP cache. */
|
|
22
|
+
export type ImageCachePolicy = 'memory-disk' | 'none'
|
|
23
|
+
|
|
24
|
+
export interface ImageLoadEvent {
|
|
25
|
+
/** Decoded bitmap size in pixels. */
|
|
26
|
+
width: number
|
|
27
|
+
height: number
|
|
28
|
+
/** Where the engine served the image from. Absent on web — the browser
|
|
29
|
+
* does not report which cache (if any) satisfied an <img>. */
|
|
30
|
+
source?: 'network' | 'memory' | 'disk' | 'local'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ImageErrorEvent {
|
|
34
|
+
error: Error
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Cache sizing/eviction applied through `initializeImageCache`. Native only —
|
|
38
|
+
* the web engine (browser HTTP cache) offers no equivalent knobs. */
|
|
39
|
+
export interface ImageCacheConfig {
|
|
40
|
+
/** In-memory bitmap cache size in bytes (Android Glide). */
|
|
41
|
+
memoryCacheSize?: number
|
|
42
|
+
/** Size the memory cache as N fullscreen bitmaps (Android Glide). */
|
|
43
|
+
memoryCacheScreens?: number
|
|
44
|
+
/** Persist the cache-key registry across launches so evictions stay
|
|
45
|
+
* consistent (Android SharedPreferences store). */
|
|
46
|
+
persistentCacheKeys?: boolean
|
|
47
|
+
/** Bump to invalidate every cached entry — Glide `ObjectKey` signature. */
|
|
48
|
+
globalSignatureKey?: string
|
|
49
|
+
/** iOS: track transformed cache keys for eviction. Needed when decodeWidth/
|
|
50
|
+
* decodeHeight (or other transforms) are used alongside evictImage —
|
|
51
|
+
* transformed images key differently than the raw URL. */
|
|
52
|
+
iosComplexCacheEviction?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Result of `isImageCached`. 'none' also covers "unknown" — on web the
|
|
56
|
+
* browser cache is not queryable, so web always reports 'none'. */
|
|
57
|
+
export type ImageCacheState = 'memory' | 'disk' | 'none'
|
|
58
|
+
|
|
59
|
+
/** Options for `prefetch` — the same contract `@octane-xplat/gif`'s
|
|
60
|
+
* `prefetch` carries. */
|
|
61
|
+
export interface PrefetchOptions {
|
|
62
|
+
/** Request headers forwarded on Android and iOS (the framework patch
|
|
63
|
+
* restores iOS request modifiers). Ignored on web — a plain <img>
|
|
64
|
+
* cannot send custom headers. */
|
|
65
|
+
headers?: Record<string, string>
|
|
66
|
+
/** Max URLs fetched in parallel (JS-side cap). Default 5 — the same
|
|
67
|
+
* ceiling NS core's ImageCache used. */
|
|
68
|
+
concurrency?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Display-load context for `isImageCached`. On iOS the engine folds the
|
|
72
|
+
* transform a load used into the cache key — decode bounds are the common
|
|
73
|
+
* one, and the leaf always decodes to a size (explicit props or the view's
|
|
74
|
+
* measured size), so a bare probe misses entries a display load wrote.
|
|
75
|
+
* Repeat the same decode bounds here to probe the key a display load hits.
|
|
76
|
+
* Android keys probes on the URL alone and ignores these — but the plugin
|
|
77
|
+
* cannot see Glide's active resources there, so a bitmap still on screen
|
|
78
|
+
* may report 'none'. Web reports 'none' regardless. */
|
|
79
|
+
export interface ImageCacheQueryOptions {
|
|
80
|
+
/** Device-pixel decode bounds — same meaning as the `Image` props. */
|
|
81
|
+
decodeWidth?: number
|
|
82
|
+
decodeHeight?: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Image — drop-in alternative to `@octane-xplat/ui`'s Image backed by a real
|
|
86
|
+
* image engine (Glide on Android, SDWebImage on iOS via
|
|
87
|
+
* `@nativescript-community/ui-image`): sized in-memory bitmap cache, disk
|
|
88
|
+
* cache, decode-to-view-size, placeholders and transitions. Web renders a
|
|
89
|
+
* plain <img>. Every `@octane-xplat/ui` Image prop is accepted with the same
|
|
90
|
+
* meaning — the members below are engine extensions with no core equivalent;
|
|
91
|
+
* they are ignored on web. SVG `src` is the one behavioral gap — keep core
|
|
92
|
+
* `Image` for those. One asymmetry: `decoding='sync'` is not honorably
|
|
93
|
+
* supported — Glide/SDWebImage have no synchronous decode mode (the plugin's
|
|
94
|
+
* `loadMode` prop is registered but never read), so 'sync' degrades to
|
|
95
|
+
* async on native with a console warning rather than a fake. */
|
|
96
|
+
export interface ImageProps extends CoreImageProps {
|
|
97
|
+
/** Native only — 'none' bypasses memory and disk caches for this image.
|
|
98
|
+
* Default 'memory-disk' leaves both caches active. */
|
|
99
|
+
cachePolicy?: ImageCachePolicy
|
|
100
|
+
/** Native only — image shown when `src` fails (`failureImageUri`). */
|
|
101
|
+
failureImage?: string
|
|
102
|
+
/** Native only — extra HTTP headers for the request (authenticated URLs). */
|
|
103
|
+
headers?: Record<string, string>
|
|
104
|
+
/** Native only — explicit decode bounds in device pixels. Default is the
|
|
105
|
+
* view's laid-out size (the src is held until first layout so the engine
|
|
106
|
+
* never decodes at full source resolution). */
|
|
107
|
+
decodeWidth?: number
|
|
108
|
+
decodeHeight?: number
|
|
109
|
+
/** Native only — progressive/interlaced rendering while bytes stream. */
|
|
110
|
+
progressive?: boolean
|
|
111
|
+
/** Native only — crossfade duration in ms when the image lands (0
|
|
112
|
+
* disables; engine default applies when unset). */
|
|
113
|
+
fadeDuration?: number
|
|
114
|
+
/** Fired when the final image is set. `source` reports which cache level
|
|
115
|
+
* served the bitmap — native only; web fires without `source`. */
|
|
116
|
+
onLoad?: (event: ImageLoadEvent) => void
|
|
117
|
+
/** Fired when the load fails. */
|
|
118
|
+
onError?: (event: ImageErrorEvent) => void
|
|
119
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"files": [
|
|
4
|
+
"Image.d.ts",
|
|
5
|
+
"Image.web.d.ts",
|
|
6
|
+
"cache.d.ts",
|
|
7
|
+
"cache.web.d.ts",
|
|
8
|
+
"hash-image.d.ts",
|
|
9
|
+
"image-binding.d.ts",
|
|
10
|
+
"image-content.d.ts",
|
|
11
|
+
"index.d.ts",
|
|
12
|
+
"index.web.d.ts",
|
|
13
|
+
"prefetch-batch.d.ts",
|
|
14
|
+
"props.d.ts"
|
|
15
|
+
]
|
|
16
|
+
}
|