@octane-xplat/ui 0.10.0 → 0.11.1
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/dist/native/Image.tsrx.js +151 -16
- package/dist/native/hash-image.js +285 -0
- package/dist/native/image-content-binding.js +158 -0
- package/dist/native/image-content.js +206 -0
- package/dist/native/image-sizing.js +102 -0
- package/dist/native/octane-client-build.json +1 -1
- package/dist/native/vendor/ui-svg/index.android.js +1 -1
- package/dist/web/Image.web.tsrx.js +112 -19
- package/dist/web/hash-image.js +285 -0
- package/dist/web/image-content.js +133 -0
- package/dist/web/octane-client-build.json +1 -1
- package/package.json +5 -4
- package/src/Image.macos.tsrx +11 -2
- package/src/Image.tsrx +203 -17
- package/src/Image.web.tsrx +91 -5
- package/src/Tabs.macos.tsrx +2 -0
- package/src/Timestamp.macos.tsrx +1 -1
- package/src/colorScheme.macos.ts +18 -5
- package/src/hash-image.test.ts +72 -0
- package/src/hash-image.ts +310 -0
- package/src/hosted-unsupported.macos.tsrx +1 -2
- package/src/image-content-binding.mobile.test.ts +174 -0
- package/src/image-content-binding.ts +231 -0
- package/src/image-content.mobile.test.tsrx +84 -0
- package/src/image-content.test.ts +219 -0
- package/src/image-content.ts +309 -0
- package/src/image-decoding.web.test.tsrx +26 -0
- package/src/image-placeholder.mobile.test.tsrx +113 -0
- package/src/image-placeholder.web.test.tsrx +48 -0
- package/src/image-recycling.mobile.test.ts +61 -0
- package/src/image-sizing.mobile.test.ts +158 -0
- package/src/image-sizing.ts +117 -0
- package/src/image.mobile.test.tsrx +81 -0
- package/src/index.macos.ts +1 -0
- package/src/index.shared.ts +7 -0
- package/src/index.shared.web.ts +7 -0
- package/src/list-viewability-observer.ios.ts +1 -0
- package/src/list-viewability.mobile.test.ts +1 -0
- package/src/props.ts +100 -1
- package/src/safeAreaInsets.macos.tsrx +6 -1
- package/src/styled.macos.tsrx +1 -1
- package/src/text-controls.macos.tsrx +3 -1
- package/src/vendor/ui-svg/src/index.android.ts +5 -1
- package/types/generated/.tsrx-typegen-manifest.json +170 -0
- package/types/generated/Accordion.macos.d.ts +5 -0
- package/types/generated/Alert.macos.d.ts +3 -0
- package/types/generated/AlertDialog.macos.d.ts +4 -0
- package/types/generated/AppShell.macos.d.ts +7 -0
- package/types/generated/AspectRatio.macos.d.ts +5 -0
- package/types/generated/Avatar.macos.d.ts +6 -0
- package/types/generated/AvatarGroup.macos.d.ts +6 -0
- package/types/generated/Badge.macos.d.ts +5 -0
- package/types/generated/Banner.macos.d.ts +4 -0
- package/types/generated/BaseTypeahead.macos.d.ts +7 -0
- package/types/generated/Blockquote.macos.d.ts +6 -0
- package/types/generated/BottomSheet.macos.d.ts +3 -0
- package/types/generated/Breadcrumb.macos.d.ts +4 -0
- package/types/generated/ButtonGroup.macos.d.ts +3 -0
- package/types/generated/Calendar.macos.d.ts +5 -0
- package/types/generated/Card.macos.d.ts +4 -0
- package/types/generated/Carousel.macos.d.ts +6 -0
- package/types/generated/Center.macos.d.ts +4 -0
- package/types/generated/CheckIndicator.macos.d.ts +4 -0
- package/types/generated/CheckboxGroup.macos.d.ts +3 -0
- package/types/generated/CheckboxIndicator.macos.d.ts +4 -0
- package/types/generated/Chip.macos.d.ts +5 -0
- package/types/generated/Citation.macos.d.ts +4 -0
- package/types/generated/ClickableCard.macos.d.ts +3 -0
- package/types/generated/Code.macos.d.ts +5 -0
- package/types/generated/CodeBlock.macos.d.ts +6 -0
- package/types/generated/Collapsible.macos.d.ts +5 -0
- package/types/generated/CommandPalette.macos.d.ts +3 -0
- package/types/generated/ComplexSelector.macos.d.ts +3 -0
- package/types/generated/ContextMenu.macos.d.ts +5 -0
- package/types/generated/DateInput.macos.d.ts +6 -0
- package/types/generated/DateRangeInput.macos.d.ts +5 -0
- package/types/generated/DateTimeInput.macos.d.ts +5 -0
- package/types/generated/Dialog.macos.d.ts +3 -0
- package/types/generated/Drawer.macos.d.ts +7 -0
- package/types/generated/DropdownMenu.macos.d.ts +5 -0
- package/types/generated/Empty.macos.d.ts +4 -0
- package/types/generated/FieldGroup.macos.d.ts +4 -0
- package/types/generated/FormField.macos.d.ts +4 -0
- package/types/generated/FormLayout.macos.d.ts +6 -0
- package/types/generated/HStack.macos.d.ts +4 -0
- package/types/generated/Heading.macos.d.ts +3 -0
- package/types/generated/Icon.macos.d.ts +3 -0
- package/types/generated/IconButton.macos.d.ts +3 -0
- package/types/generated/Image.d.ts +0 -5
- package/types/generated/Image.macos.d.ts +2 -0
- package/types/generated/Image.web.d.ts +12 -1
- package/types/generated/InputGroup.macos.d.ts +5 -0
- package/types/generated/InputNumber.macos.d.ts +5 -0
- package/types/generated/InputRating.macos.d.ts +4 -0
- package/types/generated/Item.macos.d.ts +3 -0
- package/types/generated/Kbd.macos.d.ts +4 -0
- package/types/generated/KeyboardAvoiding.macos.d.ts +3 -0
- package/types/generated/Lightbox.macos.d.ts +2 -0
- package/types/generated/Link.macos.d.ts +3 -0
- package/types/generated/List.macos.d.ts +4 -0
- package/types/generated/ListItem.macos.d.ts +5 -0
- package/types/generated/MenuParts.macos.d.ts +9 -0
- package/types/generated/MenuSurface.macos.d.ts +4 -0
- package/types/generated/MetadataList.macos.d.ts +4 -0
- package/types/generated/MetadataListItem.macos.d.ts +3 -0
- package/types/generated/Meter.macos.d.ts +3 -0
- package/types/generated/MobileNav.macos.d.ts +15 -0
- package/types/generated/MoreMenu.macos.d.ts +2 -0
- package/types/generated/NavIcon.macos.d.ts +4 -0
- package/types/generated/NavLink.macos.d.ts +3 -0
- package/types/generated/NavMenu.macos.d.ts +11 -0
- package/types/generated/NavigationMenu.macos.d.ts +4 -0
- package/types/generated/Outline.macos.d.ts +5 -0
- package/types/generated/OverflowList.macos.d.ts +6 -0
- package/types/generated/Overlay.macos.d.ts +3 -0
- package/types/generated/Pagination.macos.d.ts +4 -0
- package/types/generated/PinInput.macos.d.ts +4 -0
- package/types/generated/Popover.macos.d.ts +3 -0
- package/types/generated/PowerSearch.macos.d.ts +7 -0
- package/types/generated/PowerSearchFilterEditor.macos.d.ts +12 -0
- package/types/generated/PowerSearchToken.macos.d.ts +7 -0
- package/types/generated/Pressable.macos.d.ts +2 -0
- package/types/generated/ProgressBar.macos.d.ts +6 -0
- package/types/generated/ProgressGroup.macos.d.ts +3 -0
- package/types/generated/RadioGroup.macos.d.ts +5 -0
- package/types/generated/RadioIndicator.macos.d.ts +3 -0
- package/types/generated/ResizeHandle.macos.d.ts +7 -0
- package/types/generated/RouteBoundary.macos.d.ts +8 -0
- package/types/generated/RouteHost.macos.d.ts +13 -0
- package/types/generated/Row.macos.d.ts +2 -0
- package/types/generated/SafeArea.macos.d.ts +3 -0
- package/types/generated/Screen.macos.d.ts +3 -0
- package/types/generated/ScrollableArea.macos.d.ts +7 -0
- package/types/generated/Section.macos.d.ts +5 -0
- package/types/generated/Select.macos.d.ts +3 -0
- package/types/generated/SelectableCard.macos.d.ts +3 -0
- package/types/generated/Separator.macos.d.ts +5 -0
- package/types/generated/SideNav.macos.d.ts +19 -0
- package/types/generated/Skeleton.macos.d.ts +5 -0
- package/types/generated/StatusDot.macos.d.ts +5 -0
- package/types/generated/Stepper.macos.d.ts +4 -0
- package/types/generated/TabList.macos.d.ts +18 -0
- package/types/generated/Table.macos.d.ts +4 -0
- package/types/generated/Tabs.macos.d.ts +3 -0
- package/types/generated/Text.macos.d.ts +5 -0
- package/types/generated/Thumbnail.macos.d.ts +7 -0
- package/types/generated/TimeInput.macos.d.ts +6 -0
- package/types/generated/TimeWheelPanel.macos.d.ts +16 -0
- package/types/generated/Timeline.macos.d.ts +3 -0
- package/types/generated/Timer.macos.d.ts +3 -0
- package/types/generated/Timestamp.macos.d.ts +5 -0
- package/types/generated/Toast.macos.d.ts +3 -0
- package/types/generated/ToggleButton.macos.d.ts +3 -0
- package/types/generated/Token.macos.d.ts +5 -0
- package/types/generated/Tokenizer.macos.d.ts +6 -0
- package/types/generated/Toolbar.macos.d.ts +6 -0
- package/types/generated/TopNav.macos.d.ts +25 -0
- package/types/generated/Tree.macos.d.ts +4 -0
- package/types/generated/Typeahead.macos.d.ts +8 -0
- package/types/generated/TypeaheadItem.macos.d.ts +5 -0
- package/types/generated/User.macos.d.ts +5 -0
- package/types/generated/VStack.macos.d.ts +4 -0
- package/types/generated/View.macos.d.ts +3 -0
- package/types/generated/VirtualList.macos.d.ts +6 -0
- package/types/generated/VisuallyHidden.macos.d.ts +5 -0
- package/types/generated/WebView.macos.d.ts +2 -0
- package/types/generated/aliases.macos.d.ts +3 -0
- package/types/generated/anim.macos.d.ts +3 -0
- package/types/generated/animation-host.macos.d.ts +3 -0
- package/types/generated/bottom-sheet-panel.macos.d.ts +7 -0
- package/types/generated/bottom-sheet-service.macos.d.ts +5 -0
- package/types/generated/clipboard-write.macos.d.ts +3 -0
- package/types/generated/colorScheme.macos.d.ts +4 -0
- package/types/generated/command-palette-keys.macos.d.ts +2 -0
- package/types/generated/command-palette-scroll.macos.d.ts +2 -0
- package/types/generated/controls.macos.d.ts +8 -0
- package/types/generated/date-composition.macos.d.ts +5 -0
- package/types/generated/escape-props.macos.d.ts +5 -0
- package/types/generated/hash-image.d.ts +13 -0
- package/types/generated/hosted-unsupported.macos.d.ts +1 -0
- package/types/generated/image-content-binding.d.ts +64 -0
- package/types/generated/image-content.d.ts +56 -0
- package/types/generated/image-sizing.d.ts +37 -0
- package/types/generated/index.macos.d.ts +196 -0
- package/types/generated/index.shared.d.ts +1 -1
- package/types/generated/index.shared.web.d.ts +1 -1
- package/types/generated/layout-shells.macos.d.ts +3 -0
- package/types/generated/layout.macos.d.ts +9 -0
- package/types/generated/macos-extras.macos.d.ts +4 -0
- package/types/generated/measure-bounds.macos.d.ts +3 -0
- package/types/generated/nav-href.macos.d.ts +4 -0
- package/types/generated/outline-dom.macos.d.ts +2 -0
- package/types/generated/pan.macos.d.ts +3 -0
- package/types/generated/platform.macos.d.ts +1 -0
- package/types/generated/presentation.macos.d.ts +15 -0
- package/types/generated/props.d.ts +76 -1
- package/types/generated/query-storage.macos.d.ts +8 -0
- package/types/generated/resize-basis.macos.d.ts +4 -0
- package/types/generated/resize-persist.macos.d.ts +8 -0
- package/types/generated/route.macos.d.ts +28 -0
- package/types/generated/safeAreaInsets.macos.d.ts +10 -0
- package/types/generated/stacks.macos.d.ts +3 -0
- package/types/generated/styled.macos.d.ts +9 -0
- package/types/generated/surfaces.macos.d.ts +4 -0
- package/types/generated/svg-image.macos.d.ts +3 -0
- package/types/generated/text-controls.macos.d.ts +4 -0
- package/types/generated/theme/theme-scheme.macos.d.ts +8 -0
- package/types/generated/toast-service.macos.d.ts +3 -0
- package/types/generated/toast-viewport.macos.d.ts +5 -0
- package/types/generated/translate.macos.d.ts +1 -0
- package/types/generated/unsupported.macos.d.ts +1 -0
- package/types/generated/use-imperative-alert-dialog.macos.d.ts +2 -0
- package/types/generated/use-imperative-dialog.macos.d.ts +2 -0
- package/types/generated/use-lightbox.macos.d.ts +2 -0
- package/types/generated/use-scrollable-area.macos.d.ts +6 -0
- package/types/generated/useMeasure.macos.d.ts +3 -0
- package/types/generated/useResizable.macos.d.ts +8 -0
- package/types/generated/useStore.macos.d.ts +2 -0
- package/types/generated/windows.macos.d.ts +2 -0
- package/src/CameraView.macos.tsrx +0 -6
- package/types/index.macos.d.ts +0 -448
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { bestImageSource, contentRect } from './image-content';
|
|
2
|
+
import type {
|
|
3
|
+
ImageContentFit,
|
|
4
|
+
ImageContentPositionObject,
|
|
5
|
+
ImageSourceLike,
|
|
6
|
+
} from './props';
|
|
7
|
+
|
|
8
|
+
export interface ImageContentEnv {
|
|
9
|
+
/** Platform gate for the intrinsic-size unit conversion — the leaf
|
|
10
|
+
* supplies it so this module stays free of '@nativescript/core'. */
|
|
11
|
+
isAndroid: boolean;
|
|
12
|
+
/** Device pixel ratio for the L8 pixel-count target. */
|
|
13
|
+
screenScale: () => number;
|
|
14
|
+
/** Src write seam — the sized-decode binding (image-sizing.ts) owns WHEN
|
|
15
|
+
* a source lands on the view: first-layout gate, decode dims, resize
|
|
16
|
+
* reload. `stretchForDecode` overrides the view's stretch for those
|
|
17
|
+
* gates on the computed path, whose inner image draws stretch='fill'
|
|
18
|
+
* under a different semantic fit ('none' needs the full-resolution
|
|
19
|
+
* source to find the natural size). */
|
|
20
|
+
issue: (view: any, source: any, stretchForDecode?: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ImageContentUpdate {
|
|
24
|
+
/** The resolved single source (string or ImageSource), null when
|
|
25
|
+
* `sources` carries the request. */
|
|
26
|
+
source: any;
|
|
27
|
+
/** Resolution-qualified sources (L8) — the binding defers selection
|
|
28
|
+
* until a real layout exists, picks the closest pixel count, then
|
|
29
|
+
* hands the winner to `issue`. */
|
|
30
|
+
sources: readonly ImageSourceLike[] | null;
|
|
31
|
+
/** Resolved CSS fit/position for the computed path. */
|
|
32
|
+
fit: ImageContentFit;
|
|
33
|
+
position: ImageContentPositionObject;
|
|
34
|
+
/** True when the image view lives in a clip host and is laid out by the
|
|
35
|
+
* binding (width/height/left/top in dips) — needed for 'none',
|
|
36
|
+
* 'scale-down', and non-center positions no platform scaleType has. */
|
|
37
|
+
computed: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Intrinsic size in dips. iOS `UIImage.size` is already points; Android
|
|
41
|
+
* `Bitmap` reports raw pixels — density-bucketed resource bitmaps convert
|
|
42
|
+
* through their bucket (a 300px xxxhdpi asset = 75 dips), while decoded
|
|
43
|
+
* remote/file bitmaps carry no density and treat px as dips, which is the
|
|
44
|
+
* CSS 'none' natural size on every platform. */
|
|
45
|
+
function intrinsicDipSize(
|
|
46
|
+
imageSource: any,
|
|
47
|
+
android: boolean,
|
|
48
|
+
): { w: number; h: number } | null {
|
|
49
|
+
const width = imageSource?.width;
|
|
50
|
+
const height = imageSource?.height;
|
|
51
|
+
if (!width || !height) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (android) {
|
|
56
|
+
const density = imageSource.android?.getDensity?.() ?? 0;
|
|
57
|
+
if (density > 0) {
|
|
58
|
+
return { w: (width * 160) / density, h: (height * 160) / density };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { w: width, h: height };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Measure → compute → apply for Image's content contract (study L7).
|
|
66
|
+
* NS's stock widget owns its draw path — the widgets ImageView computes
|
|
67
|
+
* its own matrix in onDraw and honors only the four stretch scaleTypes —
|
|
68
|
+
* so the drawable-level matrix expo-image uses is engine internals. What
|
|
69
|
+
* the leaf can own is the view-level equivalent: measure the box
|
|
70
|
+
* (`layoutChanged` → `getActualSize`, dips), compute the content rect
|
|
71
|
+
* with the same math as object-fit/object-position, and lay out the
|
|
72
|
+
* inner <image stretch="fill"> to that rect inside a clipped host. One
|
|
73
|
+
* code path produces identical rects on iOS and Android.
|
|
74
|
+
*
|
|
75
|
+
* This binding never writes `src` itself — it resolves WHICH source the
|
|
76
|
+
* view should load and hands it to `env.issue`, the sized-decode binding
|
|
77
|
+
* (study L1). That seam composes both contracts: the L8 array pick waits
|
|
78
|
+
* on the same first real layout the decode gate needs, and the computed
|
|
79
|
+
* path passes the semantic fit as the decode gate so 'none' still
|
|
80
|
+
* decodes at source resolution while 'fill' would have been
|
|
81
|
+
* view-sampled.
|
|
82
|
+
*
|
|
83
|
+
* Before the bitmap's intrinsic size is known the computed path sizes
|
|
84
|
+
* the inner image to the host box: a real rect is what lets the decode
|
|
85
|
+
* gate's first-layout check pass, and the placeholder is the CSS 'fill'
|
|
86
|
+
* answer until imageSourceChange refines it.
|
|
87
|
+
*
|
|
88
|
+
* `update` re-runs on every render (ref identity churn); all writes are
|
|
89
|
+
* idempotent. Null refs on detach are ignored — a dead listener is a
|
|
90
|
+
* self-contained cycle GC collects with the view. */
|
|
91
|
+
export function createImageContentBinding(env: ImageContentEnv) {
|
|
92
|
+
let sizeView: any = null;
|
|
93
|
+
let imageView: any = null;
|
|
94
|
+
let update: ImageContentUpdate = {
|
|
95
|
+
source: null,
|
|
96
|
+
sources: null,
|
|
97
|
+
fit: 'cover',
|
|
98
|
+
position: {},
|
|
99
|
+
computed: false,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let appliedW = 0;
|
|
103
|
+
let appliedH = 0;
|
|
104
|
+
let appliedX = 0;
|
|
105
|
+
let appliedY = 0;
|
|
106
|
+
let listening = false;
|
|
107
|
+
let sourceListening = false;
|
|
108
|
+
|
|
109
|
+
const screenScale = () => {
|
|
110
|
+
try {
|
|
111
|
+
return env.screenScale();
|
|
112
|
+
} catch {
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// 'none' decodes at source resolution — its contract is natural size and
|
|
118
|
+
// the rect math needs the true intrinsic. Everything else decodes at
|
|
119
|
+
// view size: for 'scale-down' the intrinsic-vs-view comparison survives
|
|
120
|
+
// downsampling (a bigger source still reads >= view), though a source
|
|
121
|
+
// barely larger than the view can sample to just under it and resolve
|
|
122
|
+
// as 'none' — a small undersize vs CSS, accepted for one decode pass.
|
|
123
|
+
const decodeGate = () => (update.fit === 'none' ? 'none' : 'aspectFit');
|
|
124
|
+
|
|
125
|
+
const evaluate = () => {
|
|
126
|
+
if (!sizeView) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const needsViewSize = update.computed || update.sources != null;
|
|
131
|
+
let viewWidth = 0;
|
|
132
|
+
let viewHeight = 0;
|
|
133
|
+
if (needsViewSize) {
|
|
134
|
+
const actual = sizeView.getActualSize?.();
|
|
135
|
+
viewWidth = actual?.width ?? 0;
|
|
136
|
+
viewHeight = actual?.height ?? 0;
|
|
137
|
+
if (viewWidth <= 0 || viewHeight <= 0) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (imageView) {
|
|
143
|
+
let resolved: any = update.source;
|
|
144
|
+
if (update.sources?.length) {
|
|
145
|
+
resolved = bestImageSource(update.sources, viewWidth, viewHeight, screenScale())?.uri;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (resolved != null && resolved !== '') {
|
|
149
|
+
env.issue(imageView, resolved, update.computed ? decodeGate() : undefined);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!update.computed || !imageView) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const intrinsic = intrinsicDipSize(imageView.imageSource, env.isAndroid);
|
|
158
|
+
const rect = intrinsic
|
|
159
|
+
? contentRect(update.fit, update.position, viewWidth, viewHeight, intrinsic.w, intrinsic.h)
|
|
160
|
+
: { x: 0, y: 0, width: viewWidth, height: viewHeight };
|
|
161
|
+
|
|
162
|
+
if (
|
|
163
|
+
rect.width !== appliedW ||
|
|
164
|
+
rect.height !== appliedH ||
|
|
165
|
+
rect.x !== appliedX ||
|
|
166
|
+
rect.y !== appliedY
|
|
167
|
+
) {
|
|
168
|
+
appliedW = rect.width;
|
|
169
|
+
appliedH = rect.height;
|
|
170
|
+
appliedX = rect.x;
|
|
171
|
+
appliedY = rect.y;
|
|
172
|
+
imageView.width = rect.width;
|
|
173
|
+
imageView.height = rect.height;
|
|
174
|
+
imageView.left = rect.x;
|
|
175
|
+
imageView.top = rect.y;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const onLayout = () => evaluate();
|
|
180
|
+
const onImageSource = () => evaluate();
|
|
181
|
+
|
|
182
|
+
const listen = () => {
|
|
183
|
+
const wantsMeasure = update.computed || update.sources != null;
|
|
184
|
+
if (wantsMeasure && !listening && typeof sizeView?.on === 'function') {
|
|
185
|
+
sizeView.on('layoutChanged', onLayout);
|
|
186
|
+
listening = true;
|
|
187
|
+
} else if (!wantsMeasure && listening) {
|
|
188
|
+
sizeView?.off?.('layoutChanged', onLayout);
|
|
189
|
+
listening = false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (update.computed && !sourceListening && typeof imageView?.on === 'function') {
|
|
193
|
+
imageView.on('imageSourceChange', onImageSource);
|
|
194
|
+
sourceListening = true;
|
|
195
|
+
} else if (!update.computed && sourceListening) {
|
|
196
|
+
imageView?.off?.('imageSourceChange', onImageSource);
|
|
197
|
+
sourceListening = false;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
/** (Re)bind views + intent. `sizeView` is the measured box (the image
|
|
203
|
+
* itself on the stretch path, the clip host on the computed path);
|
|
204
|
+
* `imageView` is the <image> that draws. */
|
|
205
|
+
update(nextSizeView: any, nextImageView: any, next: ImageContentUpdate): void {
|
|
206
|
+
if (nextSizeView && nextSizeView !== sizeView) {
|
|
207
|
+
sizeView?.off?.('layoutChanged', onLayout);
|
|
208
|
+
sizeView = nextSizeView;
|
|
209
|
+
listening = false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (nextImageView && nextImageView !== imageView) {
|
|
213
|
+
imageView?.off?.('imageSourceChange', onImageSource);
|
|
214
|
+
imageView = nextImageView;
|
|
215
|
+
sourceListening = false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
update = next;
|
|
219
|
+
listen();
|
|
220
|
+
evaluate();
|
|
221
|
+
},
|
|
222
|
+
dispose(): void {
|
|
223
|
+
sizeView?.off?.('layoutChanged', onLayout);
|
|
224
|
+
imageView?.off?.('imageSourceChange', onImageSource);
|
|
225
|
+
sizeView = null;
|
|
226
|
+
imageView = null;
|
|
227
|
+
listening = false;
|
|
228
|
+
sourceListening = false;
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { createObjectContainer, createObjectDriver, createUniversalRoot } from 'octane/universal/native'
|
|
4
|
+
|
|
5
|
+
vi.mock('@nativescript/core', () => ({
|
|
6
|
+
isIOS: false,
|
|
7
|
+
isAndroid: true,
|
|
8
|
+
Screen: { mainScreen: { scale: 3 } },
|
|
9
|
+
ScrollView: class {},
|
|
10
|
+
ImageSource: class {
|
|
11
|
+
static fromBase64Sync() {
|
|
12
|
+
return { mocked: true }
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
// The raster path is under test; ./svg resolves to svg.mobile.ts, which
|
|
18
|
+
// registers SVGView from the vendored submodule (needs real NS core).
|
|
19
|
+
vi.mock('./svg', () => ({
|
|
20
|
+
isSvgSrc: () => false,
|
|
21
|
+
svgSource: (s: string) => s,
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
import { Image } from './Image.tsrx'
|
|
25
|
+
|
|
26
|
+
function render(props: any) {
|
|
27
|
+
const container = createObjectContainer('nativescript')
|
|
28
|
+
const root = createUniversalRoot(container, createObjectDriver('nativescript'))
|
|
29
|
+
root.render(Image as any, props)
|
|
30
|
+
return { container, root, host: container.children[0] as any }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('native Image — contentFit fast path (L7)', () => {
|
|
34
|
+
it('defaults to cover → aspectFill (the web/img fill and NS aspectFit defaults diverged)', () => {
|
|
35
|
+
const { host } = render({ src: 'https://img.example/x.png' })
|
|
36
|
+
expect(host.type).toBe('image')
|
|
37
|
+
expect(host.props.stretch).toBe('aspectFill')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('maps contain/cover/fill onto stretch', () => {
|
|
41
|
+
expect(render({ src: 'x', contentFit: 'contain' }).host.props.stretch).toBe('aspectFit')
|
|
42
|
+
expect(render({ src: 'x', contentFit: 'cover' }).host.props.stretch).toBe('aspectFill')
|
|
43
|
+
expect(render({ src: 'x', contentFit: 'fill' }).host.props.stretch).toBe('fill')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('routes none/scale-down/positioned content through the computed wrapper', () => {
|
|
47
|
+
const none = render({ src: 'x', contentFit: 'none' }).host
|
|
48
|
+
expect(none.type).toBe('absolutelayout')
|
|
49
|
+
const inner = none.children[0] as any
|
|
50
|
+
expect(inner.type).toBe('image')
|
|
51
|
+
expect(inner.props.stretch).toBe('fill')
|
|
52
|
+
|
|
53
|
+
const scaleDown = render({ src: 'x', contentFit: 'scale-down' }).host
|
|
54
|
+
expect(scaleDown.type).toBe('absolutelayout')
|
|
55
|
+
const positioned = render({ src: 'x', contentFit: 'cover', contentPosition: 'top left' }).host
|
|
56
|
+
expect(positioned.type).toBe('absolutelayout')
|
|
57
|
+
// fill ignores position — the rect is always the whole box
|
|
58
|
+
const filled = render({ src: 'x', contentFit: 'fill', contentPosition: 'top left' }).host
|
|
59
|
+
expect(filled.type).toBe('image')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('native Image — multi-source selection (L8)', () => {
|
|
64
|
+
const sources = [
|
|
65
|
+
{ uri: 'https://img.example/p-200.jpg', width: 200, height: 200 },
|
|
66
|
+
{ uri: 'https://img.example/p-800.jpg', width: 800, height: 800 },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
it('defers src until a real layout, then assigns the closest source', () => {
|
|
70
|
+
const { root, host } = render({ src: sources })
|
|
71
|
+
expect(host.type).toBe('image')
|
|
72
|
+
expect(host.src).toBeUndefined()
|
|
73
|
+
|
|
74
|
+
// re-render re-fires the ref → binding.update → content binding picks
|
|
75
|
+
// (200x200 dips at scale 3 → 600² px → 800² wins) and hands it to the
|
|
76
|
+
// sized binding, whose measured-width gate lands the src + decode dims
|
|
77
|
+
host.getActualSize = () => ({ width: 200, height: 200 })
|
|
78
|
+
host.getMeasuredWidth = () => 600
|
|
79
|
+
host.getMeasuredHeight = () => 600
|
|
80
|
+
root.render(Image as any, { src: sources })
|
|
81
|
+
expect(host.src).toBe('https://img.example/p-800.jpg')
|
|
82
|
+
expect(host.decodeWidth).toEqual({ value: 600, unit: 'px' })
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
bestImageSource,
|
|
4
|
+
contentAxisOffset,
|
|
5
|
+
contentFitToStretch,
|
|
6
|
+
contentRect,
|
|
7
|
+
imageSrcSet,
|
|
8
|
+
isCenteredContentPosition,
|
|
9
|
+
needsContentRect,
|
|
10
|
+
objectPositionCSSValue,
|
|
11
|
+
resolveContentFit,
|
|
12
|
+
resolveContentPosition,
|
|
13
|
+
} from './image-content'
|
|
14
|
+
|
|
15
|
+
const CENTER = { top: '50%', left: '50%' } as const
|
|
16
|
+
|
|
17
|
+
describe('resolveContentFit / resolveContentPosition', () => {
|
|
18
|
+
it('defaults to cover and 50%/50%', () => {
|
|
19
|
+
expect(resolveContentFit(undefined)).toBe('cover')
|
|
20
|
+
expect(resolveContentPosition(undefined)).toEqual(CENTER)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('maps keyword shorthands like expo-image', () => {
|
|
24
|
+
expect(resolveContentPosition('top right')).toEqual({ top: 0, right: 0 })
|
|
25
|
+
expect(resolveContentPosition('bottom')).toEqual({ bottom: 0, left: '50%' })
|
|
26
|
+
expect(resolveContentPosition('left center')).toEqual({ top: '50%', left: 0 })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('falls back to center for invalid keywords', () => {
|
|
30
|
+
expect(resolveContentPosition('diagonal' as any)).toEqual(CENTER)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('detects centered positions including explicit 50% edges', () => {
|
|
34
|
+
expect(isCenteredContentPosition({})).toBe(true)
|
|
35
|
+
expect(isCenteredContentPosition({ left: '50%' })).toBe(true)
|
|
36
|
+
expect(isCenteredContentPosition({ right: '50%' })).toBe(true)
|
|
37
|
+
expect(isCenteredContentPosition({ left: 'center' })).toBe(true)
|
|
38
|
+
expect(isCenteredContentPosition({ top: 0 })).toBe(false)
|
|
39
|
+
expect(isCenteredContentPosition({ left: 10 })).toBe(false)
|
|
40
|
+
expect(isCenteredContentPosition({ right: '25%' })).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('contentAxisOffset', () => {
|
|
45
|
+
const img = 200
|
|
46
|
+
const view = 400
|
|
47
|
+
|
|
48
|
+
it('centers by default and honors percentages over the free space', () => {
|
|
49
|
+
expect(contentAxisOffset(view, img, undefined, undefined)).toBe(100)
|
|
50
|
+
expect(contentAxisOffset(view, img, '0%', undefined)).toBe(0)
|
|
51
|
+
expect(contentAxisOffset(view, img, '25%', undefined)).toBe(50)
|
|
52
|
+
expect(contentAxisOffset(view, img, 'center', undefined)).toBe(100)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('measures absolute dips from the named edge', () => {
|
|
56
|
+
expect(contentAxisOffset(view, img, 10, undefined)).toBe(10)
|
|
57
|
+
expect(contentAxisOffset(view, img, undefined, 10)).toBe(view - img - 10)
|
|
58
|
+
expect(contentAxisOffset(view, img, undefined, '75%')).toBe(50)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('contentRect', () => {
|
|
63
|
+
// 200x100 image inside a 400x300 box
|
|
64
|
+
it('contain letterboxes centered', () => {
|
|
65
|
+
expect(contentRect('contain', CENTER, 400, 300, 200, 100)).toEqual({
|
|
66
|
+
x: 0,
|
|
67
|
+
y: 50,
|
|
68
|
+
width: 400,
|
|
69
|
+
height: 200,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('cover overflows evenly with negative offset', () => {
|
|
74
|
+
expect(contentRect('cover', CENTER, 400, 300, 200, 100)).toEqual({
|
|
75
|
+
x: -100,
|
|
76
|
+
y: 0,
|
|
77
|
+
width: 600,
|
|
78
|
+
height: 300,
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('cover + top left anchors the crop', () => {
|
|
83
|
+
expect(contentRect('cover', { top: 0, left: 0 }, 400, 300, 200, 100)).toEqual({
|
|
84
|
+
x: 0,
|
|
85
|
+
y: 0,
|
|
86
|
+
width: 600,
|
|
87
|
+
height: 300,
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('cover + right 25% shifts into the overflow', () => {
|
|
92
|
+
expect(contentRect('cover', { right: '25%', top: '50%' }, 400, 300, 200, 100).x).toBe(-150)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('fill ignores position entirely', () => {
|
|
96
|
+
expect(contentRect('fill', { top: 0, left: 0 }, 400, 300, 200, 100)).toEqual({
|
|
97
|
+
x: 0,
|
|
98
|
+
y: 0,
|
|
99
|
+
width: 400,
|
|
100
|
+
height: 300,
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('none keeps the intrinsic size, centered', () => {
|
|
105
|
+
expect(contentRect('none', CENTER, 400, 300, 200, 100)).toEqual({
|
|
106
|
+
x: 100,
|
|
107
|
+
y: 100,
|
|
108
|
+
width: 200,
|
|
109
|
+
height: 100,
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('scale-down shrinks to contain when the image is bigger, else none', () => {
|
|
114
|
+
expect(contentRect('scale-down', CENTER, 400, 300, 800, 600)).toEqual({
|
|
115
|
+
x: 0,
|
|
116
|
+
y: 0,
|
|
117
|
+
width: 400,
|
|
118
|
+
height: 300,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(contentRect('scale-down', CENTER, 400, 300, 200, 100)).toEqual({
|
|
122
|
+
x: 100,
|
|
123
|
+
y: 100,
|
|
124
|
+
width: 200,
|
|
125
|
+
height: 100,
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('absolute dip positions pin to the named edge', () => {
|
|
130
|
+
const rect = contentRect('contain', { bottom: 10, right: 20 }, 400, 300, 200, 100)
|
|
131
|
+
// content is 400x200 → bottom-edge offset = 300-200-10, right = 400-400-20
|
|
132
|
+
expect(rect.y).toBe(90)
|
|
133
|
+
expect(rect.x).toBe(-20)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('needsContentRect / contentFitToStretch', () => {
|
|
138
|
+
it('uses the fast path for expressible fits', () => {
|
|
139
|
+
expect(needsContentRect('cover', CENTER)).toBe(false)
|
|
140
|
+
expect(needsContentRect('contain', CENTER)).toBe(false)
|
|
141
|
+
expect(needsContentRect('fill', CENTER)).toBe(false)
|
|
142
|
+
// fill's rect is the whole box — position can't change it
|
|
143
|
+
expect(needsContentRect('fill', { top: 0, left: 0 })).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('needs the computed path for none, scale-down, and positioned content', () => {
|
|
147
|
+
expect(needsContentRect('none', CENTER)).toBe(true)
|
|
148
|
+
expect(needsContentRect('scale-down', CENTER)).toBe(true)
|
|
149
|
+
expect(needsContentRect('cover', { top: 0 })).toBe(true)
|
|
150
|
+
expect(needsContentRect('contain', { left: '25%' })).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('maps fits onto the stretch vocabulary', () => {
|
|
154
|
+
expect(contentFitToStretch('contain')).toBe('aspectFit')
|
|
155
|
+
expect(contentFitToStretch('cover')).toBe('aspectFill')
|
|
156
|
+
expect(contentFitToStretch('fill')).toBe('fill')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('bestImageSource', () => {
|
|
161
|
+
const sources = [
|
|
162
|
+
{ uri: 'https://img.example/photo-100.jpg', width: 100, height: 100 },
|
|
163
|
+
{ uri: 'https://img.example/photo-400.jpg', width: 400, height: 400 },
|
|
164
|
+
{ uri: 'https://img.example/photo-1600.jpg', width: 1600, height: 1600 },
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
it('picks the closest pixel count to the view', () => {
|
|
168
|
+
// 200x200 dips at scale 3 → 600x600 device px → closest is 400²
|
|
169
|
+
const best = bestImageSource(sources, 200, 200, 3)
|
|
170
|
+
expect(best?.uri).toBe('https://img.example/photo-400.jpg')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('weighs the source scale multiplier', () => {
|
|
174
|
+
const dense = [
|
|
175
|
+
{ uri: 'a', width: 100, height: 100, scale: 1 },
|
|
176
|
+
{ uri: 'b', width: 100, height: 100, scale: 3 },
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
// 100 dips at scale 3 → 300px target; b is 300px-worth, a is 100
|
|
180
|
+
expect(bestImageSource(dense, 100, 100, 3)?.uri).toBe('b')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('returns the only source, and nothing before a real layout', () => {
|
|
184
|
+
expect(bestImageSource(sources, 0, 0, 3)).toBeNull()
|
|
185
|
+
expect(bestImageSource([sources[0]], 100, 100, 1)?.uri).toBe(sources[0].uri)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('skips unsized sources when sized candidates exist', () => {
|
|
189
|
+
const mixed = [{ uri: 'unsized' }, sources[1]]
|
|
190
|
+
expect(bestImageSource(mixed, 100, 100, 3)?.uri).toBe(sources[1].uri)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('objectPositionCSSValue / imageSrcSet', () => {
|
|
195
|
+
it('emits edge-value object-position', () => {
|
|
196
|
+
expect(objectPositionCSSValue({ top: 0, right: '25%' })).toBe('top 0px right 25%')
|
|
197
|
+
expect(objectPositionCSSValue({ bottom: 10, left: '50%' })).toBe('bottom 10px left 50%')
|
|
198
|
+
expect(objectPositionCSSValue({})).toBe('top 50% left 50%')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('builds w-descriptor srcset from width·scale', () => {
|
|
202
|
+
const { src, srcSet } = imageSrcSet([
|
|
203
|
+
{ uri: 'a.jpg', width: 100, height: 100 },
|
|
204
|
+
{ uri: 'b.jpg', width: 200, height: 200, scale: 2 },
|
|
205
|
+
])
|
|
206
|
+
|
|
207
|
+
expect(srcSet).toBe('a.jpg 100w, b.jpg 400w')
|
|
208
|
+
expect(src).toBe('b.jpg')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('builds x-descriptor srcset when only density is known', () => {
|
|
212
|
+
const { srcSet } = imageSrcSet([
|
|
213
|
+
{ uri: 'a.png', scale: 1 },
|
|
214
|
+
{ uri: 'a@2x.png', scale: 2 },
|
|
215
|
+
])
|
|
216
|
+
|
|
217
|
+
expect(srcSet).toBe('a.png 1x, a@2x.png 2x')
|
|
218
|
+
})
|
|
219
|
+
})
|