@octane-xplat/image 0.0.0-stage → 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 +123 -2
- 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 +83 -3
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,86 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/image",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "Cached image loading for Octane xplat — Glide/SDWebImage engine (ui-image) on native, <img> on web",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"android",
|
|
7
|
+
"cache",
|
|
8
|
+
"cross-platform",
|
|
9
|
+
"glide",
|
|
10
|
+
"image",
|
|
11
|
+
"ios",
|
|
12
|
+
"nativescript",
|
|
13
|
+
"octane",
|
|
14
|
+
"sdwebimage",
|
|
15
|
+
"web",
|
|
16
|
+
"xplat"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/image",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/octane-xplat/octane-xplat",
|
|
23
|
+
"directory": "packages/image"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"types",
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"macos": {
|
|
34
|
+
"types": "./types/index.macos.d.ts",
|
|
35
|
+
"default": "./src/index.macos.ts"
|
|
36
|
+
},
|
|
37
|
+
"web": {
|
|
38
|
+
"types": "./types/generated/index.web.d.ts",
|
|
39
|
+
"default": "./dist/web/index.js"
|
|
40
|
+
},
|
|
41
|
+
"native": {
|
|
42
|
+
"types": "./types/generated/index.d.ts",
|
|
43
|
+
"default": "./dist/native/index.js"
|
|
44
|
+
},
|
|
45
|
+
"types": "./types/generated/index.d.ts",
|
|
46
|
+
"default": "./dist/native/index.js"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@nativescript-community/ui-image": "5.0.17"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@nativescript-community/octane": "0.2.4",
|
|
57
|
+
"@nativescript/core": "9.1.2",
|
|
58
|
+
"@octane-xplat/ui": "0.11.0",
|
|
59
|
+
"@octanejs/vite-plugin": "0.1.61",
|
|
60
|
+
"@tsrx/typescript-plugin": "0.4.11",
|
|
61
|
+
"octane": "0.6.3",
|
|
62
|
+
"tsrx-typegen": "0.2.2",
|
|
63
|
+
"typescript": "5.9.3",
|
|
64
|
+
"vite": "8.3.0"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
68
|
+
"@nativescript/core": ">=9.1.0 <10",
|
|
69
|
+
"@octane-xplat/ui": ">=0.10.0 <1",
|
|
70
|
+
"octane": ">=0.6.3 <1"
|
|
71
|
+
},
|
|
72
|
+
"peerDependenciesMeta": {
|
|
73
|
+
"@nativescript-community/octane": {
|
|
74
|
+
"optional": true
|
|
75
|
+
},
|
|
76
|
+
"@nativescript/core": {
|
|
77
|
+
"optional": true
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"scripts": {
|
|
81
|
+
"build": "vite build && vite build --mode native && pnpm typegen && node ../../scripts/check-native-dist.mjs",
|
|
82
|
+
"test:packed": "node tests/packed-consumer.mjs",
|
|
83
|
+
"typegen": "tsrx-typegen --target octane",
|
|
84
|
+
"pack:check": "tsrx-typegen --pack-check"
|
|
85
|
+
}
|
|
6
86
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** @jsxImportSource @octane-xplat/macos-renderer */
|
|
2
|
+
import type { ImageProps } from './props'
|
|
3
|
+
|
|
4
|
+
/** Image — the AppKit host's <image>. The engine leaf has no macOS backend
|
|
5
|
+
* (Glide/SDWebImage are iOS/Android), so this mirrors the core AppKit path:
|
|
6
|
+
* local src grammar, first array entry for multi-source, contentFit →
|
|
7
|
+
* style.objectFit ('cover' degrades to scale-down — AppKit has no cover
|
|
8
|
+
* scaling mode); contentPosition is unimplemented here. Engine props are
|
|
9
|
+
* ignored — no cache engine exists on this target. */
|
|
10
|
+
export function Image(props: ImageProps) @{
|
|
11
|
+
const src = (Array.isArray(props.src) ? props.src[0]?.uri : props.src) ?? ''
|
|
12
|
+
const style = {
|
|
13
|
+
objectFit: props.contentFit ?? 'cover',
|
|
14
|
+
...props.style,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
<image
|
|
18
|
+
id={props.id}
|
|
19
|
+
src={src}
|
|
20
|
+
className={props.className}
|
|
21
|
+
{style}
|
|
22
|
+
accessibilityLabel={props.alt ?? ''}
|
|
23
|
+
/>
|
|
24
|
+
}
|
package/src/Image.tsrx
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import { registerElement } from '@nativescript-community/octane';
|
|
3
|
+
import { ImageSource, isAndroid, isIOS, Screen } from '@nativescript/core';
|
|
4
|
+
import { Img, initialize } from '@nativescript-community/ui-image';
|
|
5
|
+
import { useMemo, useRef } from 'octane';
|
|
6
|
+
import type { ImageProps } from './props';
|
|
7
|
+
import { hashToPngBase64 } from './hash-image';
|
|
8
|
+
import {
|
|
9
|
+
contentFitToStretch,
|
|
10
|
+
needsContentRect,
|
|
11
|
+
resolveContentFit,
|
|
12
|
+
resolveContentPosition,
|
|
13
|
+
} from './image-content';
|
|
14
|
+
|
|
15
|
+
import { createImageBinding } from './image-binding';
|
|
16
|
+
|
|
17
|
+
// @nativescript-community/ui-image v5 — Glide + okhttp3 on Android (sized LRU
|
|
18
|
+
// memory cache + BitmapPool + disk cache), SDWebImage on iOS. `noCache` maps to
|
|
19
|
+
// skipMemoryCache + DiskCacheStrategy.NONE / SDWebImage FromLoaderOnly.
|
|
20
|
+
registerElement('cachedimage', Img);
|
|
21
|
+
|
|
22
|
+
let syncDecodeWarned = false;
|
|
23
|
+
|
|
24
|
+
let engineStarted = false;
|
|
25
|
+
function ensureEngine(): void {
|
|
26
|
+
if (!engineStarted) {
|
|
27
|
+
// Android auto-inits inside createNativeView anyway; calling early covers
|
|
28
|
+
// iOS loader registration and keeps an initializeImageCache() config the
|
|
29
|
+
// winner when it ran before first render (the plugin guards on its own
|
|
30
|
+
// `initialized` flag).
|
|
31
|
+
initialize();
|
|
32
|
+
engineStarted = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LAYOUT_KEYS = [
|
|
37
|
+
'row',
|
|
38
|
+
'col',
|
|
39
|
+
'rowSpan',
|
|
40
|
+
'colSpan',
|
|
41
|
+
'dock',
|
|
42
|
+
'left',
|
|
43
|
+
'top',
|
|
44
|
+
'flexGrow',
|
|
45
|
+
'flexShrink',
|
|
46
|
+
'alignSelf',
|
|
47
|
+
'order',
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
function layoutChildAttrs(props: ImageProps) {
|
|
51
|
+
const result: Record<string, any> = {};
|
|
52
|
+
for (const key of LAYOUT_KEYS) {
|
|
53
|
+
if (props[key] !== undefined) {
|
|
54
|
+
result[key] = props[key];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function applyEscapeProps(view: any, props: ImageProps): void {
|
|
62
|
+
const bag = isIOS ? props.ios : isAndroid ? props.android : undefined;
|
|
63
|
+
if (bag) {
|
|
64
|
+
Object.assign(view, bag);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A `src` string → engine source. data: URIs decode to an ImageSource — the
|
|
69
|
+
* engine has no data: scheme. */
|
|
70
|
+
function resolveSrc(src: string): string | ImageSource {
|
|
71
|
+
const comma = src.indexOf(',');
|
|
72
|
+
if (src.startsWith('data:') && comma > 0 && src.slice(0, comma).endsWith(';base64')) {
|
|
73
|
+
return ImageSource.fromBase64Sync(src.slice(comma + 1));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return src;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `placeholder` → what the engine's placeholderImageUri accepts: `res://`,
|
|
80
|
+
* `~/`, and file paths pass through as strings; `blurhash:`/`thumbhash:` and
|
|
81
|
+
* `data:` decode to an ImageSource (the plugin's getDrawable/getUIImage
|
|
82
|
+
* accept the instance even though the prop is typed `string`). Remote URLs
|
|
83
|
+
* are ignored on native, matching core Image — a placeholder that must fetch
|
|
84
|
+
* first is pointless. */
|
|
85
|
+
function placeholderValue(placeholder: string): string | ImageSource | null {
|
|
86
|
+
const png = hashToPngBase64(placeholder);
|
|
87
|
+
if (png) {
|
|
88
|
+
return ImageSource.fromBase64Sync(png);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (/^https?:\/\//.test(placeholder)) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return resolveSrc(placeholder);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Image — ui-image `Img` (Glide/SDWebImage). Mirrors the core contract:
|
|
99
|
+
* `src` takes the Img grammar (remote URL, res://name, ~/bundle path, file
|
|
100
|
+
* path, data: URI) plus a resolution-qualified `ImageSourceLike[]` (closest
|
|
101
|
+
* pixel count wins once the view has a real layout — the srcset model).
|
|
102
|
+
* `contentFit` maps to stretch for the centered contain/cover/fill cases;
|
|
103
|
+
* 'none'/'scale-down'/positioned content take the computed path — the host
|
|
104
|
+
* box is measured, the content rect computed, and a fill-stretched `Img`
|
|
105
|
+
* laid out inside a clipped absolutelayout (same structure core uses).
|
|
106
|
+
* `src` never goes through JSX: the binding holds it until first layout,
|
|
107
|
+
* writes decodeWidth/decodeHeight in device px, then assigns — an eager src
|
|
108
|
+
* decodes at full source resolution. SVG is unsupported — keep core `Image`
|
|
109
|
+
* for those. `alt` maps to accessibilityLabel. `decoding` accepts the shared
|
|
110
|
+
* vocabulary but 'sync' degrades to async with a warning — the engines have
|
|
111
|
+
* no synchronous decode mode (see props.ts). */
|
|
112
|
+
export function Image(props: ImageProps) @{
|
|
113
|
+
ensureEngine();
|
|
114
|
+
|
|
115
|
+
// `decoding='sync'` has no honest path: Glide `.into()` and SDWebImage
|
|
116
|
+
// `sd_setImageWithURL` always decode off-thread, and the plugin's
|
|
117
|
+
// `loadMode` prop is registered but never read. Degrade to the async
|
|
118
|
+
// default with a note rather than setting dead surface.
|
|
119
|
+
if (props.decoding === 'sync' && !syncDecodeWarned) {
|
|
120
|
+
syncDecodeWarned = true;
|
|
121
|
+
console.warn(
|
|
122
|
+
"[octane-xplat] Image: decoding='sync' is not supported by the Glide/SDWebImage engine — decode stays async.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const latest = useRef(props);
|
|
127
|
+
latest.current = props;
|
|
128
|
+
|
|
129
|
+
const fit = resolveContentFit(props.contentFit);
|
|
130
|
+
const position = resolveContentPosition(props.contentPosition);
|
|
131
|
+
const computed = needsContentRect(fit, position);
|
|
132
|
+
const imageRef = useRef<any>(null);
|
|
133
|
+
const hostRef = useRef<any>(null);
|
|
134
|
+
|
|
135
|
+
const binding = useMemo(
|
|
136
|
+
() =>
|
|
137
|
+
createImageBinding({
|
|
138
|
+
getFit: () => resolveContentFit(latest.current.contentFit),
|
|
139
|
+
getPosition: () => resolveContentPosition(latest.current.contentPosition),
|
|
140
|
+
getSrc: () => {
|
|
141
|
+
const src = latest.current.src;
|
|
142
|
+
return Array.isArray(src)
|
|
143
|
+
? { source: null, sources: src }
|
|
144
|
+
: { source: resolveSrc(src), sources: null };
|
|
145
|
+
},
|
|
146
|
+
getDecodeBounds: () => ({
|
|
147
|
+
width: latest.current.decodeWidth,
|
|
148
|
+
height: latest.current.decodeHeight,
|
|
149
|
+
}),
|
|
150
|
+
screenScale: () => Screen.mainScreen?.scale ?? 1,
|
|
151
|
+
}),
|
|
152
|
+
[],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const boundRef = useRef({ key: props.recyclingKey, src: props.src });
|
|
156
|
+
const bound = boundRef.current;
|
|
157
|
+
boundRef.current = { key: props.recyclingKey, src: props.src };
|
|
158
|
+
// expo-image `recyclingKey`: platform-list cells rebind the same view, and
|
|
159
|
+
// the engine keeps drawing the previous bitmap until the new fetch
|
|
160
|
+
// resolves. Clear it before the ref re-binds the next src — only when
|
|
161
|
+
// identity AND source both changed (same src = same bitmap).
|
|
162
|
+
if (
|
|
163
|
+
bound.key != null &&
|
|
164
|
+
props.recyclingKey != null &&
|
|
165
|
+
bound.key !== props.recyclingKey &&
|
|
166
|
+
bound.src !== props.src &&
|
|
167
|
+
imageRef.current
|
|
168
|
+
) {
|
|
169
|
+
const nativeView = imageRef.current.nativeViewProtected;
|
|
170
|
+
if (nativeView) {
|
|
171
|
+
if (isIOS) {
|
|
172
|
+
nativeView.image = null;
|
|
173
|
+
} else {
|
|
174
|
+
nativeView.setImageDrawable?.(null);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const attach = (view: any) => {
|
|
180
|
+
if (!view) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
imageRef.current = view;
|
|
185
|
+
applyEscapeProps(view, props);
|
|
186
|
+
const ph = latest.current.placeholder;
|
|
187
|
+
if (ph) {
|
|
188
|
+
const resolved = placeholderValue(ph);
|
|
189
|
+
if (resolved != null) {
|
|
190
|
+
// string | ImageSource — the prop is typed `string` but the
|
|
191
|
+
// plugin's placeholder resolution accepts an ImageSource on
|
|
192
|
+
// both platforms.
|
|
193
|
+
view.placeholderImageUri = resolved;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!view.__xplatImageWired) {
|
|
198
|
+
view.__xplatImageWired = true;
|
|
199
|
+
view.on(Img.finalImageSetEvent, (e: any) => {
|
|
200
|
+
const info = e.imageInfo;
|
|
201
|
+
const w = info?.getWidth?.() ?? info?.width ?? 0;
|
|
202
|
+
const h = info?.getHeight?.() ?? info?.height ?? 0;
|
|
203
|
+
binding.setIntrinsic(w, h);
|
|
204
|
+
latest.current.onLoad?.({ width: w, height: h, source: e.source });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
view.on(Img.failureEvent, (e: any) => {
|
|
208
|
+
latest.current.onError?.({ error: e.error });
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
@if (computed) {
|
|
214
|
+
<absolutelayout
|
|
215
|
+
ref={(v: any) => {
|
|
216
|
+
hostRef.current = v;
|
|
217
|
+
if (v && isIOS && v.ios) {
|
|
218
|
+
// cover/positioned rects must clip at the component box (iOS
|
|
219
|
+
// defaults overflow:visible).
|
|
220
|
+
v.ios.clipsToBounds = true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (v) {
|
|
224
|
+
binding.update(imageRef.current, v);
|
|
225
|
+
}
|
|
226
|
+
}}
|
|
227
|
+
id={props.id}
|
|
228
|
+
className={props.className}
|
|
229
|
+
style={props.style}
|
|
230
|
+
{...layoutChildAttrs(props)}
|
|
231
|
+
>
|
|
232
|
+
<cachedimage
|
|
233
|
+
ref={(v: any) => {
|
|
234
|
+
attach(v);
|
|
235
|
+
if (v) {
|
|
236
|
+
binding.update(v, hostRef.current);
|
|
237
|
+
}
|
|
238
|
+
}}
|
|
239
|
+
stretch="fill"
|
|
240
|
+
failureImageUri={props.failureImage}
|
|
241
|
+
headers={props.headers}
|
|
242
|
+
noCache={props.cachePolicy === 'none'}
|
|
243
|
+
progressiveRenderingEnabled={props.progressive}
|
|
244
|
+
fadeDuration={props.fadeDuration}
|
|
245
|
+
accessibilityLabel={props.alt}
|
|
246
|
+
/>
|
|
247
|
+
</absolutelayout>
|
|
248
|
+
} @else {
|
|
249
|
+
<cachedimage
|
|
250
|
+
ref={(v: any) => {
|
|
251
|
+
attach(v);
|
|
252
|
+
if (v) {
|
|
253
|
+
binding.update(v, null);
|
|
254
|
+
}
|
|
255
|
+
}}
|
|
256
|
+
id={props.id}
|
|
257
|
+
className={props.className}
|
|
258
|
+
style={props.style}
|
|
259
|
+
{...layoutChildAttrs(props)}
|
|
260
|
+
stretch={contentFitToStretch(fit)}
|
|
261
|
+
failureImageUri={props.failureImage}
|
|
262
|
+
headers={props.headers}
|
|
263
|
+
noCache={props.cachePolicy === 'none'}
|
|
264
|
+
progressiveRenderingEnabled={props.progressive}
|
|
265
|
+
fadeDuration={props.fadeDuration}
|
|
266
|
+
accessibilityLabel={props.alt}
|
|
267
|
+
/>
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef } from 'octane';
|
|
2
|
+
import type { ImageProps, ImageSourceLike } from './props';
|
|
3
|
+
import {
|
|
4
|
+
imageSrcSet,
|
|
5
|
+
objectPositionCSSValue,
|
|
6
|
+
resolveContentFit,
|
|
7
|
+
resolveContentPosition,
|
|
8
|
+
} from './image-content';
|
|
9
|
+
|
|
10
|
+
import { hashToPngBase64 } from './hash-image';
|
|
11
|
+
|
|
12
|
+
/** Parent-layout metadata → CSS. Mirrors the ui web mapping: grid indices are
|
|
13
|
+
* zero-based, dock has no CSS equivalent and is ignored. */
|
|
14
|
+
function layoutChildStyle(props: ImageProps) {
|
|
15
|
+
const style: Record<string, any> = {};
|
|
16
|
+
if (props.row !== undefined) {
|
|
17
|
+
style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`;
|
|
18
|
+
} else if (props.rowSpan !== undefined) {
|
|
19
|
+
style.gridRow = `span ${props.rowSpan}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (props.col !== undefined) {
|
|
23
|
+
style.gridColumn = `${props.col + 1} / span ${props.colSpan ?? 1}`;
|
|
24
|
+
} else if (props.colSpan !== undefined) {
|
|
25
|
+
style.gridColumn = `span ${props.colSpan}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (props.left !== undefined) {
|
|
29
|
+
style.left = props.left;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (props.top !== undefined) {
|
|
33
|
+
style.top = props.top;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (props.flexGrow !== undefined) {
|
|
37
|
+
style.flexGrow = props.flexGrow;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (props.flexShrink !== undefined) {
|
|
41
|
+
style.flexShrink = props.flexShrink;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (props.alignSelf !== undefined) {
|
|
45
|
+
style.alignSelf = props.alignSelf;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (props.order !== undefined) {
|
|
49
|
+
style.order = props.order;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return style;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Dash-named escape-bag keys are attributes; other keys use the DOM property
|
|
56
|
+
* where available. Mirrors ui's web escape-props. */
|
|
57
|
+
function applyEscapeProps(element: any, props: ImageProps): void {
|
|
58
|
+
if (!element || !props.web) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const [key, value] of Object.entries(props.web)) {
|
|
63
|
+
if (key.includes('-')) {
|
|
64
|
+
if (value == null || value === false) {
|
|
65
|
+
element.removeAttribute(key);
|
|
66
|
+
} else {
|
|
67
|
+
element.setAttribute(key, String(value));
|
|
68
|
+
}
|
|
69
|
+
} else if (key in element) {
|
|
70
|
+
element[key] = value;
|
|
71
|
+
} else if (value != null && value !== false) {
|
|
72
|
+
element.setAttribute(key, String(value));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `blurhash:`/`thumbhash:` decode to a data: PNG (./hash-image — the ui
|
|
78
|
+
* copy); every other value passes through as an ordinary <img> URL (a
|
|
79
|
+
* low-res source URL is a fine placeholder on web). */
|
|
80
|
+
function placeholderUrl(placeholder: string): string {
|
|
81
|
+
const png = hashToPngBase64(placeholder);
|
|
82
|
+
return png ? `data:image/png;base64,${png}` : placeholder;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Image — a plain <img>, mirroring the core contract: a source array becomes
|
|
86
|
+
* a real `srcset` (browser owns selection); `contentFit`/`contentPosition`
|
|
87
|
+
* map 1:1 to object-fit/object-position with 'cover'/'50% 50%' defaults;
|
|
88
|
+
* `placeholder` rides the same element — the img keeps its last decoded
|
|
89
|
+
* image while a hidden prefetch warms the final src, so placeholder → final
|
|
90
|
+
* swaps in one view without a blank frame. Engine-only props (cachePolicy,
|
|
91
|
+
* failureImage, headers, decodeWidth/decodeHeight, progressive,
|
|
92
|
+
* fadeDuration) have no <img> equivalent and are ignored. onLoad fires
|
|
93
|
+
* without `source` — the browser does not report which cache satisfied the
|
|
94
|
+
* request. */
|
|
95
|
+
export function Image(props: ImageProps) @{
|
|
96
|
+
const imgRef = useRef<HTMLImageElement | null>(null);
|
|
97
|
+
const sources = Array.isArray(props.src) ? (props.src as ImageSourceLike[]) : null;
|
|
98
|
+
const selected = sources
|
|
99
|
+
? imageSrcSet(sources)
|
|
100
|
+
: { src: props.src as string, srcSet: undefined };
|
|
101
|
+
|
|
102
|
+
useLayoutEffect(() => {
|
|
103
|
+
const img = imgRef.current;
|
|
104
|
+
// complete = the current src already resolved (cache/sync data URI) —
|
|
105
|
+
// showing the placeholder would flash backwards.
|
|
106
|
+
if (!img || !props.placeholder || img.complete) return;
|
|
107
|
+
const placeholder = placeholderUrl(props.placeholder);
|
|
108
|
+
const { src: final, srcSet: finalSrcSet } = selected;
|
|
109
|
+
// (document.createElement — `Image` here is this component, not the
|
|
110
|
+
// global HTMLImageElement constructor)
|
|
111
|
+
const warm = document.createElement('img');
|
|
112
|
+
warm.src = final;
|
|
113
|
+
if (finalSrcSet) {
|
|
114
|
+
warm.srcset = finalSrcSet;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const swap = () => {
|
|
118
|
+
img.src = final;
|
|
119
|
+
if (finalSrcSet) {
|
|
120
|
+
// srcset overrides src while present — restore it after the
|
|
121
|
+
// src write so both point at the final selection.
|
|
122
|
+
img.srcset = finalSrcSet;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
img.addEventListener('load', swap, { once: true });
|
|
127
|
+
img.addEventListener('error', swap, { once: true });
|
|
128
|
+
if (finalSrcSet) {
|
|
129
|
+
// While srcset is set it wins over src — clear it or the
|
|
130
|
+
// placeholder assignment below is inert.
|
|
131
|
+
img.srcset = '';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
img.src = placeholder;
|
|
135
|
+
return () => {
|
|
136
|
+
img.removeEventListener('load', swap);
|
|
137
|
+
img.removeEventListener('error', swap);
|
|
138
|
+
};
|
|
139
|
+
}, [props.src, props.placeholder]);
|
|
140
|
+
|
|
141
|
+
const style = {
|
|
142
|
+
objectFit: 'cover',
|
|
143
|
+
objectPosition: '50% 50%',
|
|
144
|
+
...layoutChildStyle(props),
|
|
145
|
+
...(typeof props.style === 'object' ? props.style : {}),
|
|
146
|
+
...(props.contentFit !== undefined
|
|
147
|
+
? { objectFit: resolveContentFit(props.contentFit) }
|
|
148
|
+
: null),
|
|
149
|
+
...(props.contentPosition !== undefined
|
|
150
|
+
? { objectPosition: objectPositionCSSValue(resolveContentPosition(props.contentPosition)) }
|
|
151
|
+
: null),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
<img
|
|
155
|
+
ref={(e: any) => {
|
|
156
|
+
imgRef.current = e;
|
|
157
|
+
applyEscapeProps(e, props);
|
|
158
|
+
}}
|
|
159
|
+
id={props.id}
|
|
160
|
+
className={props.className}
|
|
161
|
+
src={selected.src}
|
|
162
|
+
srcSet={selected.srcSet}
|
|
163
|
+
decoding={props.decoding ?? 'async'}
|
|
164
|
+
alt={props.alt}
|
|
165
|
+
style={style}
|
|
166
|
+
onLoad={(e: any) =>
|
|
167
|
+
props.onLoad?.({
|
|
168
|
+
width: e.currentTarget.naturalWidth ?? 0,
|
|
169
|
+
height: e.currentTarget.naturalHeight ?? 0,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
onError={() =>
|
|
173
|
+
props.onError?.({ error: new Error(`Failed to load image: ${props.src}`) })
|
|
174
|
+
}
|
|
175
|
+
/>
|
|
176
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ImageCacheConfig, ImageCacheQueryOptions, ImageCacheState, PrefetchOptions } from './props';
|
|
2
|
+
|
|
3
|
+
/** No-op — the AppKit host has no image engine. */
|
|
4
|
+
export function initializeImageCache(_config?: ImageCacheConfig): void {}
|
|
5
|
+
|
|
6
|
+
/** No-op — the AppKit host has no image pipeline to warm, so nothing is
|
|
7
|
+
* prefetched; resolves `false` to report that honestly. */
|
|
8
|
+
export function prefetch(_srcs: string | string[], _options?: PrefetchOptions): Promise<boolean> {
|
|
9
|
+
return Promise.resolve(false);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function evictImage(_src: string): Promise<boolean> {
|
|
13
|
+
return Promise.resolve(false);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function clearImageCaches(): Promise<void> {
|
|
17
|
+
return Promise.resolve();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isImageCached(
|
|
21
|
+
srcs: string | readonly string[],
|
|
22
|
+
_options?: ImageCacheQueryOptions,
|
|
23
|
+
): Promise<Record<string, ImageCacheState>> {
|
|
24
|
+
const list = typeof srcs === 'string' ? [srcs] : srcs;
|
|
25
|
+
const states: Record<string, ImageCacheState> = {};
|
|
26
|
+
for (const src of list) {
|
|
27
|
+
states[src] = 'none';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return Promise.resolve(states);
|
|
31
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { ImagePipeline, getImagePipeline, initialize } from '@nativescript-community/ui-image';
|
|
2
|
+
import { prefetchBatch } from './prefetch-batch';
|
|
3
|
+
import type { ImageCacheConfig, ImageCacheQueryOptions, ImageCacheState, PrefetchOptions } from './props';
|
|
4
|
+
|
|
5
|
+
/** Apply engine-level cache configuration. Call from the app entry before the
|
|
6
|
+
* first <Image> renders — the plugin initializes Glide lazily, so sizing must
|
|
7
|
+
* arrive before the first view creates the engine. A later call is a no-op
|
|
8
|
+
* (the plugin guards on its own `initialized` flag). Native only. */
|
|
9
|
+
export function initializeImageCache(config?: ImageCacheConfig): void {
|
|
10
|
+
initialize({
|
|
11
|
+
memoryCacheSize: config?.memoryCacheSize,
|
|
12
|
+
memoryCacheScreens: config?.memoryCacheScreens,
|
|
13
|
+
usePersistentCacheKeyStore: config?.persistentCacheKeys,
|
|
14
|
+
globalSignatureKey: config?.globalSignatureKey,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (config?.iosComplexCacheEviction !== undefined) {
|
|
18
|
+
ImagePipeline.iosComplexCacheEviction = config.iosComplexCacheEviction;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Warm the disk cache without decoding — bytes are fetched and stored, decode
|
|
23
|
+
* cost stays at display time. One URL or an array; resolves `true` when all
|
|
24
|
+
* warmed, `false` if any URL fails or the pipeline is not up yet (e.g. called
|
|
25
|
+
* before app launch). Same contract as `@octane-xplat/gif`'s `prefetch`.
|
|
26
|
+
*
|
|
27
|
+
* `options.headers` is forwarded on Android and iOS (via the framework
|
|
28
|
+
* patch). `options.concurrency` caps parallel fetches. */
|
|
29
|
+
export function prefetch(srcs: string | string[], options?: PrefetchOptions): Promise<boolean> {
|
|
30
|
+
try {
|
|
31
|
+
initialize();
|
|
32
|
+
const pipeline = getImagePipeline();
|
|
33
|
+
const { concurrency, ...engineOptions } = options ?? {};
|
|
34
|
+
return prefetchBatch(srcs, concurrency, (url) =>
|
|
35
|
+
pipeline.prefetchToDiskCache(url, engineOptions).then(
|
|
36
|
+
() => true,
|
|
37
|
+
() => false,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
return Promise.resolve(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Remove `src` from memory and disk caches. */
|
|
46
|
+
export function evictImage(src: string): Promise<boolean> {
|
|
47
|
+
initialize();
|
|
48
|
+
return getImagePipeline().evictFromCache(src);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Drop every cached image (memory + disk). */
|
|
52
|
+
export function clearImageCaches(): Promise<void> {
|
|
53
|
+
initialize();
|
|
54
|
+
return getImagePipeline().clearCaches();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The published index.d.ts omits the options params on the two presence
|
|
58
|
+
// probes — the iOS implementation folds them into the cache key through
|
|
59
|
+
// getContextFromOptions (the same context the Img display path builds), and
|
|
60
|
+
// Android ignores the extra arg since its probe keys on the URL alone.
|
|
61
|
+
// Declared locally so the probe can pass the display load's decode context.
|
|
62
|
+
interface CacheProbe {
|
|
63
|
+
isInBitmapMemoryCache(uri: string, options?: ImageCacheQueryOptions): boolean;
|
|
64
|
+
isInDiskCache(uri: string, options?: ImageCacheQueryOptions): Promise<boolean>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Report which cache level currently holds each `src` — 'memory', 'disk',
|
|
68
|
+
* or 'none' (not cached / unknown). Accepts a single URL or a list; the
|
|
69
|
+
* result is always a `Record` keyed by URL (the RN `queryCache` shape).
|
|
70
|
+
* `options` repeats the display load's decode bounds: on iOS the cache key
|
|
71
|
+
* includes the transform context, so an image displayed with
|
|
72
|
+
* decodeWidth/decodeHeight only reports its level when the probe passes
|
|
73
|
+
* the same values. Android caveat: the plugin's probe cannot see Glide's
|
|
74
|
+
* active resources, so a bitmap still on screen may report 'none' —
|
|
75
|
+
* treat 'none' as "not provably cached", not "absent". */
|
|
76
|
+
export async function isImageCached(
|
|
77
|
+
srcs: string | readonly string[],
|
|
78
|
+
options?: ImageCacheQueryOptions,
|
|
79
|
+
): Promise<Record<string, ImageCacheState>> {
|
|
80
|
+
initialize();
|
|
81
|
+
const pipeline = getImagePipeline() as unknown as CacheProbe;
|
|
82
|
+
const list = typeof srcs === 'string' ? [srcs] : srcs;
|
|
83
|
+
const states: Record<string, ImageCacheState> = {};
|
|
84
|
+
await Promise.all(
|
|
85
|
+
list.map(async (src) => {
|
|
86
|
+
states[src] = pipeline.isInBitmapMemoryCache(src, options)
|
|
87
|
+
? 'memory'
|
|
88
|
+
: ((await pipeline.isInDiskCache(src, options)) ? 'disk' : 'none');
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return states;
|
|
93
|
+
}
|