@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,309 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ImageContentFit,
|
|
3
|
+
ImageContentPosition,
|
|
4
|
+
ImageContentPositionObject,
|
|
5
|
+
ImageContentPositionValue,
|
|
6
|
+
ImageSourceLike,
|
|
7
|
+
} from './props'
|
|
8
|
+
|
|
9
|
+
/** Shared `contentFit`/`contentPosition` + multi-source math for `Image` —
|
|
10
|
+
* pure functions used by every leaf (docs/notes/expo-image-study.md L7/L8).
|
|
11
|
+
* Semantics mirror CSS `object-fit`/`object-position`, which is also
|
|
12
|
+
* expo-image's contract: the leaves compute the drawn-content rect
|
|
13
|
+
* themselves instead of delegating to platform scaleType/contentMode,
|
|
14
|
+
* so all platforms get the same answer.
|
|
15
|
+
*
|
|
16
|
+
* The default fit is 'cover' everywhere (the expo/RN default). Today's
|
|
17
|
+
* platform defaults disagree — web <img> is 'fill' and NS stretch is
|
|
18
|
+
* 'aspectFit' — so an un-propped Image was silently divergent. */
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_CONTENT_FIT: ImageContentFit = 'cover'
|
|
21
|
+
|
|
22
|
+
export function resolveContentFit(fit?: ImageContentFit): ImageContentFit {
|
|
23
|
+
return fit ?? DEFAULT_CONTENT_FIT
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** expo-image's keyword shorthands; the object form positions relative to
|
|
27
|
+
* any corner pair ({top,left}, {top,right}, {bottom,left}, {bottom,right}). */
|
|
28
|
+
const POSITION_KEYWORDS: Record<string, ImageContentPositionObject> = {
|
|
29
|
+
center: { top: '50%', left: '50%' },
|
|
30
|
+
top: { top: 0, left: '50%' },
|
|
31
|
+
right: { top: '50%', right: 0 },
|
|
32
|
+
bottom: { bottom: 0, left: '50%' },
|
|
33
|
+
left: { top: '50%', left: 0 },
|
|
34
|
+
'top center': { top: 0, left: '50%' },
|
|
35
|
+
'top right': { top: 0, right: 0 },
|
|
36
|
+
'top left': { top: 0, left: 0 },
|
|
37
|
+
'right center': { top: '50%', right: 0 },
|
|
38
|
+
'right top': { top: 0, right: 0 },
|
|
39
|
+
'right bottom': { bottom: 0, right: 0 },
|
|
40
|
+
'bottom center': { bottom: 0, left: '50%' },
|
|
41
|
+
'bottom right': { bottom: 0, right: 0 },
|
|
42
|
+
'bottom left': { bottom: 0, left: 0 },
|
|
43
|
+
'left center': { top: '50%', left: 0 },
|
|
44
|
+
'left top': { top: 0, left: 0 },
|
|
45
|
+
'left bottom': { bottom: 0, left: 0 },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveContentPosition(
|
|
49
|
+
position?: ImageContentPosition,
|
|
50
|
+
): ImageContentPositionObject {
|
|
51
|
+
if (typeof position === 'string') {
|
|
52
|
+
return POSITION_KEYWORDS[position] ?? POSITION_KEYWORDS.center
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return position ?? POSITION_KEYWORDS.center
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type AxisValue = { percent: number } | { dips: number }
|
|
59
|
+
|
|
60
|
+
function parseAxisValue(value: ImageContentPositionValue | undefined): AxisValue | null {
|
|
61
|
+
if (value == null) {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof value === 'number') {
|
|
66
|
+
return { dips: value }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const text = String(value).trim()
|
|
70
|
+
if (text === 'center') {
|
|
71
|
+
return { percent: 50 }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (text.endsWith('%')) {
|
|
75
|
+
const parsed = Number.parseFloat(text)
|
|
76
|
+
return Number.isNaN(parsed) ? null : { percent: parsed }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parsed = Number.parseFloat(text)
|
|
80
|
+
return Number.isNaN(parsed) ? null : { dips: parsed }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Offset of the drawn content's start edge inside the view along one axis.
|
|
84
|
+
* expo-image `calcTranslation`: percentages run over the free space
|
|
85
|
+
* (view − content), absolute dips measure from the named edge, and the
|
|
86
|
+
* reverse edge (right/bottom) counts back from the far side. */
|
|
87
|
+
export function contentAxisOffset(
|
|
88
|
+
viewSize: number,
|
|
89
|
+
contentSize: number,
|
|
90
|
+
start: ImageContentPositionValue | undefined,
|
|
91
|
+
end: ImageContentPositionValue | undefined,
|
|
92
|
+
): number {
|
|
93
|
+
const from = parseAxisValue(start)
|
|
94
|
+
if (from) {
|
|
95
|
+
return 'percent' in from ? (from.percent / 100) * (viewSize - contentSize) : from.dips
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const to = parseAxisValue(end)
|
|
99
|
+
if (to) {
|
|
100
|
+
return 'percent' in to
|
|
101
|
+
? (1 - to.percent / 100) * (viewSize - contentSize)
|
|
102
|
+
: viewSize - contentSize - to.dips
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (viewSize - contentSize) / 2
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function centeredAxis(
|
|
109
|
+
start: ImageContentPositionValue | undefined,
|
|
110
|
+
end: ImageContentPositionValue | undefined,
|
|
111
|
+
): boolean {
|
|
112
|
+
const from = parseAxisValue(start)
|
|
113
|
+
if (from) {
|
|
114
|
+
return 'percent' in from && from.percent === 50
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const to = parseAxisValue(end)
|
|
118
|
+
if (to) {
|
|
119
|
+
return 'percent' in to && to.percent === 50
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function isCenteredContentPosition(position: ImageContentPositionObject): boolean {
|
|
126
|
+
return (
|
|
127
|
+
centeredAxis(position.left, position.right) && centeredAxis(position.top, position.bottom)
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The rect the bitmap occupies inside the view box, in the same units the
|
|
132
|
+
* caller measures with (native: dips; web: CSS px via object-fit anyway —
|
|
133
|
+
* only the native leaf calls this). Every length unit is dips except the
|
|
134
|
+
* intrinsic size, which the leaf must supply in dips too. */
|
|
135
|
+
export function contentRect(
|
|
136
|
+
fit: ImageContentFit,
|
|
137
|
+
position: ImageContentPositionObject,
|
|
138
|
+
viewWidth: number,
|
|
139
|
+
viewHeight: number,
|
|
140
|
+
imageWidth: number,
|
|
141
|
+
imageHeight: number,
|
|
142
|
+
): { x: number; y: number; width: number; height: number } {
|
|
143
|
+
let width: number
|
|
144
|
+
let height: number
|
|
145
|
+
switch (fit) {
|
|
146
|
+
case 'fill':
|
|
147
|
+
width = viewWidth
|
|
148
|
+
height = viewHeight
|
|
149
|
+
break
|
|
150
|
+
case 'cover': {
|
|
151
|
+
const scale = Math.max(viewWidth / imageWidth, viewHeight / imageHeight)
|
|
152
|
+
width = imageWidth * scale
|
|
153
|
+
height = imageHeight * scale
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
case 'scale-down': {
|
|
157
|
+
const scale = Math.min(1, Math.min(viewWidth / imageWidth, viewHeight / imageHeight))
|
|
158
|
+
width = imageWidth * scale
|
|
159
|
+
height = imageHeight * scale
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
case 'none':
|
|
163
|
+
width = imageWidth
|
|
164
|
+
height = imageHeight
|
|
165
|
+
break
|
|
166
|
+
default: {
|
|
167
|
+
// 'contain'
|
|
168
|
+
const scale = Math.min(viewWidth / imageWidth, viewHeight / imageHeight)
|
|
169
|
+
width = imageWidth * scale
|
|
170
|
+
height = imageHeight * scale
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
x: contentAxisOffset(viewWidth, width, position.left, position.right),
|
|
176
|
+
y: contentAxisOffset(viewHeight, height, position.top, position.bottom),
|
|
177
|
+
width,
|
|
178
|
+
height,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** True when no platform stretch/scaleType can express the request and the
|
|
183
|
+
* leaf must measure + lay out the image itself: 'none' (iOS maps NS
|
|
184
|
+
* stretch='none' to TopLeft, not CSS centered), 'scale-down' (needs the
|
|
185
|
+
* intrinsic-vs-view compare), or any non-center position. 'fill' is
|
|
186
|
+
* exempt — its rect is always the whole box, so position can't matter. */
|
|
187
|
+
export function needsContentRect(
|
|
188
|
+
fit: ImageContentFit,
|
|
189
|
+
position: ImageContentPositionObject,
|
|
190
|
+
): boolean {
|
|
191
|
+
if (fit === 'none' || fit === 'scale-down') {
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return fit !== 'fill' && !isCenteredContentPosition(position)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** NS `stretch` for the fast path — only reached for fits the platform
|
|
199
|
+
* vocabulary expresses identically (contain/cover/fill centered). */
|
|
200
|
+
export function contentFitToStretch(fit: ImageContentFit): 'aspectFit' | 'aspectFill' | 'fill' {
|
|
201
|
+
switch (fit) {
|
|
202
|
+
case 'contain':
|
|
203
|
+
return 'aspectFit'
|
|
204
|
+
case 'fill':
|
|
205
|
+
return 'fill'
|
|
206
|
+
default:
|
|
207
|
+
return 'aspectFill'
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** L8 — expo `getBestSource`: the source whose declared pixel count
|
|
212
|
+
* (`width·height·scale²`, dips × density) is closest to the view's pixel
|
|
213
|
+
* count. Needs a real measured size; one source or no size picks nothing
|
|
214
|
+
* to decide. Unsized sources score worst and are skipped when any sized
|
|
215
|
+
* candidate exists. */
|
|
216
|
+
export function bestImageSource(
|
|
217
|
+
sources: readonly ImageSourceLike[],
|
|
218
|
+
viewWidth: number,
|
|
219
|
+
viewHeight: number,
|
|
220
|
+
screenScale: number,
|
|
221
|
+
): ImageSourceLike | null {
|
|
222
|
+
if (sources.length === 0 || viewWidth <= 0 || viewHeight <= 0) {
|
|
223
|
+
return null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (sources.length === 1) {
|
|
227
|
+
return sources[0]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const target = viewWidth * viewHeight * screenScale * screenScale
|
|
231
|
+
let best: ImageSourceLike | null = null
|
|
232
|
+
let bestFit = Infinity
|
|
233
|
+
for (const source of sources) {
|
|
234
|
+
const pixelCount = (source.width ?? 0) * (source.height ?? 0) * (source.scale ?? 1) ** 2
|
|
235
|
+
const fit = Math.abs(1 - pixelCount / target)
|
|
236
|
+
if (fit < bestFit) {
|
|
237
|
+
best = source
|
|
238
|
+
bestFit = fit
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return best
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function ensureWebUnits(value: ImageContentPositionValue): string {
|
|
246
|
+
const text = String(value).trim()
|
|
247
|
+
return text.endsWith('%') ? text : `${text}px`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Browser `object-position` for a resolved position — mirrors expo-image
|
|
251
|
+
* web's `getObjectPositionFromContentPositionObject`. */
|
|
252
|
+
export function objectPositionCSSValue(position: ImageContentPositionObject): string {
|
|
253
|
+
const resolved: ImageContentPositionObject = { ...position }
|
|
254
|
+
if (resolved.top == null && resolved.bottom == null) {
|
|
255
|
+
resolved.top = '50%'
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (resolved.left == null && resolved.right == null) {
|
|
259
|
+
resolved.left = '50%'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
(['top', 'bottom', 'left', 'right'] as const)
|
|
264
|
+
.map((edge) => (resolved[edge] != null ? `${edge} ${ensureWebUnits(resolved[edge]!)}` : ''))
|
|
265
|
+
.filter((part) => part !== '')
|
|
266
|
+
.join(' ') || '50% 50%'
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Browser `srcset` candidates for a source array: `w` descriptors (real
|
|
271
|
+
* file px = width·scale) when every source is sized, `x` descriptors when
|
|
272
|
+
* every source only carries density. `src` falls back to the largest
|
|
273
|
+
* declared pixel count — guaranteed coverage where srcset is ignored. */
|
|
274
|
+
export function imageSrcSet(sources: readonly ImageSourceLike[]): {
|
|
275
|
+
src: string
|
|
276
|
+
srcSet?: string
|
|
277
|
+
} {
|
|
278
|
+
if (sources.length === 0) {
|
|
279
|
+
return { src: '' }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const everyWidth = sources.every(
|
|
283
|
+
(source) => typeof source.width === 'number' && source.width > 0,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
const everyScale =
|
|
287
|
+
!everyWidth &&
|
|
288
|
+
sources.every((source) => typeof source.scale === 'number' && source.scale > 0)
|
|
289
|
+
|
|
290
|
+
const srcSet = everyWidth
|
|
291
|
+
? sources
|
|
292
|
+
.map((source) => `${source.uri} ${Math.round(source.width! * (source.scale ?? 1))}w`)
|
|
293
|
+
.join(', ')
|
|
294
|
+
: everyScale
|
|
295
|
+
? sources.map((source) => `${source.uri} ${source.scale}x`).join(', ')
|
|
296
|
+
: undefined
|
|
297
|
+
|
|
298
|
+
let fallback = sources[0]
|
|
299
|
+
let bestCount = -Infinity
|
|
300
|
+
for (const source of sources) {
|
|
301
|
+
const count = (source.width ?? 0) * (source.height ?? 0) * (source.scale ?? 1) ** 2
|
|
302
|
+
if (count > bestCount) {
|
|
303
|
+
bestCount = count
|
|
304
|
+
fallback = source
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { src: fallback.uri, srcSet }
|
|
309
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, expect, it } from 'vitest'
|
|
3
|
+
import { act, createRoot } from 'octane'
|
|
4
|
+
import { Image } from './Image.web.tsrx'
|
|
5
|
+
|
|
6
|
+
const roots: any[] = []
|
|
7
|
+
|
|
8
|
+
function mount(props: any = {}) {
|
|
9
|
+
const el = document.createElement('div')
|
|
10
|
+
document.body.append(el)
|
|
11
|
+
const root = createRoot(el)
|
|
12
|
+
roots.push(root)
|
|
13
|
+
act(() => root.render(<Image {...props} />))
|
|
14
|
+
return el.firstElementChild as HTMLImageElement
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const root of roots.splice(0)) act(() => root.unmount())
|
|
19
|
+
document.body.innerHTML = ''
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('writes the HTML decoding attribute — async by default, 1:1 when set', () => {
|
|
23
|
+
expect(mount({ src: 'https://x/a.png' }).getAttribute('decoding')).toBe('async')
|
|
24
|
+
expect(mount({ src: 'https://x/a.png', decoding: 'sync' }).getAttribute('decoding')).toBe('sync')
|
|
25
|
+
expect(mount({ src: 'https://x/a.png', decoding: 'async' }).getAttribute('decoding')).toBe('async')
|
|
26
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
createObjectContainer,
|
|
5
|
+
createObjectDriver,
|
|
6
|
+
createUniversalRoot,
|
|
7
|
+
flushUniversalSync,
|
|
8
|
+
} from 'octane/universal/native'
|
|
9
|
+
|
|
10
|
+
vi.mock('@nativescript/core', () => ({
|
|
11
|
+
isIOS: false,
|
|
12
|
+
isAndroid: true,
|
|
13
|
+
Screen: { mainScreen: { scale: 2 } },
|
|
14
|
+
ImageSource: class {
|
|
15
|
+
static fromBase64Sync() {
|
|
16
|
+
return { mocked: 'png' }
|
|
17
|
+
}
|
|
18
|
+
static fromResourceSync() {
|
|
19
|
+
return { mocked: 'res' }
|
|
20
|
+
}
|
|
21
|
+
static fromFileSync() {
|
|
22
|
+
return { mocked: 'file' }
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
knownFolders: { currentApp: () => ({ path: '/app' }) },
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
// The raster path is under test; ./svg resolves to svg.mobile.ts, which
|
|
29
|
+
// registers SVGView from the vendored submodule (needs real NS core).
|
|
30
|
+
vi.mock('./svg', () => ({
|
|
31
|
+
isSvgSrc: () => false,
|
|
32
|
+
svgSource: (s: string) => s,
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
import { Image } from './Image.tsrx'
|
|
36
|
+
|
|
37
|
+
const BLURHASH = 'blurhash:LEHV6nWB2yk8pyo0adR*.7kCMdnj'
|
|
38
|
+
|
|
39
|
+
function mount(props: any) {
|
|
40
|
+
const container = createObjectContainer('nativescript')
|
|
41
|
+
const root = createUniversalRoot(container, createObjectDriver('nativescript'))
|
|
42
|
+
root.render(Image as any, props)
|
|
43
|
+
flushUniversalSync(() => {})
|
|
44
|
+
return { container, root, host: container.children[0] as any }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sized(host: any) {
|
|
48
|
+
host.on = (event: string, fn: () => void) => {
|
|
49
|
+
if (event === 'layoutChanged') host._layoutCb = fn
|
|
50
|
+
if (event === 'isLoadingChange') host._loadCb = fn
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
host.off = () => {}
|
|
54
|
+
host.getMeasuredWidth = () => 300
|
|
55
|
+
host.getMeasuredHeight = () => 200
|
|
56
|
+
host.stretch = 'aspectFit'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('native Image placeholder (study L5)', () => {
|
|
60
|
+
it('paints the placeholder bitmap while the binding still holds src', () => {
|
|
61
|
+
const { host } = mount({ src: 'https://x/photo.png', placeholder: BLURHASH })
|
|
62
|
+
expect(host.type).toBe('image')
|
|
63
|
+
// L1 binding: src stays unissued until first layout.
|
|
64
|
+
expect(host.src).toBeUndefined()
|
|
65
|
+
expect(host.imageSource).toEqual({ mocked: 'png' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('re-applies after each src issue and leaves the final bitmap alone on completion', () => {
|
|
69
|
+
const { root, host } = mount({ src: 'https://x/a.png', placeholder: BLURHASH })
|
|
70
|
+
expect(host.imageSource).toEqual({ mocked: 'png' })
|
|
71
|
+
sized(host)
|
|
72
|
+
|
|
73
|
+
// Emulate the iOS sequence for a pending load: the previous bitmap was
|
|
74
|
+
// cleared and isLoading is already true when src is written again.
|
|
75
|
+
host.imageSource = null
|
|
76
|
+
host.isLoading = true
|
|
77
|
+
root.render(Image as any, { src: 'https://x/b.png', placeholder: BLURHASH })
|
|
78
|
+
flushUniversalSync(() => {})
|
|
79
|
+
|
|
80
|
+
// The binding issued the new src and its afterIssue hook repainted the
|
|
81
|
+
// placeholder — this is the same-src/mid-load gap that fires no
|
|
82
|
+
// isLoadingChange.
|
|
83
|
+
expect(host.src).toBe('https://x/b.png')
|
|
84
|
+
expect(host.decodeWidth).toEqual({ value: 300, unit: 'px' })
|
|
85
|
+
expect(host.imageSource).toEqual({ mocked: 'png' })
|
|
86
|
+
expect(host._loadCb).toBeTypeOf('function')
|
|
87
|
+
|
|
88
|
+
// Load completes: final bitmap arrives before isLoading goes false →
|
|
89
|
+
// the placeholder must not clobber it.
|
|
90
|
+
host.imageSource = { mocked: 'final' }
|
|
91
|
+
host.isLoading = false
|
|
92
|
+
host._loadCb()
|
|
93
|
+
expect(host.imageSource).toEqual({ mocked: 'final' })
|
|
94
|
+
|
|
95
|
+
// Failure path: Android clears imageSource on error → the listener
|
|
96
|
+
// puts the placeholder back (failure-image semantics).
|
|
97
|
+
host.imageSource = null
|
|
98
|
+
host._loadCb()
|
|
99
|
+
expect(host.imageSource).toEqual({ mocked: 'png' })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('ignores a remote placeholder URL and no-ops without placeholder', () => {
|
|
103
|
+
const { host } = mount({
|
|
104
|
+
src: 'https://x/photo.png',
|
|
105
|
+
placeholder: 'https://x/placeholder.png',
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(host.imageSource).toBeUndefined()
|
|
109
|
+
|
|
110
|
+
const { host: plain } = mount({ src: 'https://x/photo.png' })
|
|
111
|
+
expect(plain.imageSource).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
3
|
+
import { act, createRoot } from 'octane'
|
|
4
|
+
import { Image } from './Image.web.tsrx'
|
|
5
|
+
|
|
6
|
+
const roots: any[] = []
|
|
7
|
+
|
|
8
|
+
function mount(props: any = {}) {
|
|
9
|
+
vi.useFakeTimers()
|
|
10
|
+
const el = document.createElement('div')
|
|
11
|
+
document.body.append(el)
|
|
12
|
+
const root = createRoot(el)
|
|
13
|
+
roots.push(root)
|
|
14
|
+
const render = (next: any = {}) => act(() => root.render(<Image {...props} {...next} />))
|
|
15
|
+
render()
|
|
16
|
+
return { root, render, el, img: el.firstElementChild as HTMLImageElement }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const root of roots.splice(0)) act(() => root.unmount())
|
|
21
|
+
document.body.innerHTML = ''
|
|
22
|
+
vi.useRealTimers()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('serves the placeholder through the same <img>, then swaps to src on placeholder load', () => {
|
|
26
|
+
const { img } = mount({ src: 'https://x/photo.png', placeholder: 'blurhash:LEHV6nWB2yk8pyo0adR*.7kCMdnj' })
|
|
27
|
+
expect(img.getAttribute('src')).toMatch(/^data:image\/png;base64,iVBORw0KGgo/)
|
|
28
|
+
act(() => img.dispatchEvent(new Event('load')))
|
|
29
|
+
expect(img.getAttribute('src')).toBe('https://x/photo.png')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('swaps to src when the placeholder errors', () => {
|
|
33
|
+
const { img } = mount({ src: 'https://x/photo.png', placeholder: 'blurhash:LEHV6nWB2yk8pyo0adR*.7kCMdnj' })
|
|
34
|
+
act(() => img.dispatchEvent(new Event('error')))
|
|
35
|
+
expect(img.getAttribute('src')).toBe('https://x/photo.png')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('passes non-hash placeholders through as plain URLs', () => {
|
|
39
|
+
const { img } = mount({ src: 'https://x/photo.png', placeholder: 'https://x/tiny.jpg' })
|
|
40
|
+
expect(img.getAttribute('src')).toBe('https://x/tiny.jpg')
|
|
41
|
+
act(() => img.dispatchEvent(new Event('load')))
|
|
42
|
+
expect(img.getAttribute('src')).toBe('https://x/photo.png')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does not re-run the placeholder when src is already complete', () => {
|
|
46
|
+
const { img } = mount({ src: 'https://x/photo.png' })
|
|
47
|
+
expect(img.getAttribute('src')).toBe('https://x/photo.png')
|
|
48
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createObjectContainer,
|
|
4
|
+
createObjectDriver,
|
|
5
|
+
createUniversalRoot,
|
|
6
|
+
} from 'octane/universal/native'
|
|
7
|
+
|
|
8
|
+
vi.mock('@nativescript/core', () => ({
|
|
9
|
+
isIOS: false,
|
|
10
|
+
isAndroid: true,
|
|
11
|
+
ScrollView: class ScrollView {},
|
|
12
|
+
ImageSource: { fromBase64Sync: (data: string) => ({ base64: data }) },
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
// The svg leaf registers <svgview> via a vendored submodule; raster srcs
|
|
16
|
+
// never consult it, so the test stubs the router out.
|
|
17
|
+
vi.mock('./svg', () => ({
|
|
18
|
+
isSvgSrc: () => false,
|
|
19
|
+
svgSource: (src: string) => src,
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
import { Image } from './Image'
|
|
23
|
+
|
|
24
|
+
describe('Image recyclingKey', () => {
|
|
25
|
+
it('clears the previous bitmap only when a recycled cell binds a new identity', () => {
|
|
26
|
+
const container = createObjectContainer('nativescript')
|
|
27
|
+
const root = createUniversalRoot(container, createObjectDriver('nativescript'))
|
|
28
|
+
|
|
29
|
+
root.render(Image as any, { src: 'https://img/1.png', recyclingKey: 'a' })
|
|
30
|
+
const host = container.children[0] as any
|
|
31
|
+
// The sized binding holds src until the view reports a real layout.
|
|
32
|
+
host.getMeasuredWidth = () => 800
|
|
33
|
+
host.getMeasuredHeight = () => 600
|
|
34
|
+
|
|
35
|
+
// Simulate a completed load: the native view holds a decoded bitmap.
|
|
36
|
+
host.imageSource = { bitmap: 'first' }
|
|
37
|
+
|
|
38
|
+
// Same identity, new src — the bitmap is still this item's predecessor;
|
|
39
|
+
// it stays while the sized binding issues the replacement.
|
|
40
|
+
root.render(Image as any, { src: 'https://img/2.png', recyclingKey: 'a' })
|
|
41
|
+
expect(host.imageSource).toEqual({ bitmap: 'first' })
|
|
42
|
+
expect(host.src).toBe('https://img/2.png')
|
|
43
|
+
|
|
44
|
+
// Recycled cell rebound to a different item — blank before the new load.
|
|
45
|
+
root.render(Image as any, { src: 'https://img/3.png', recyclingKey: 'b' })
|
|
46
|
+
expect(host.imageSource).toBeNull()
|
|
47
|
+
expect(host.src).toBe('https://img/3.png')
|
|
48
|
+
|
|
49
|
+
// New identity but identical src — the bitmap is already correct.
|
|
50
|
+
host.imageSource = { bitmap: 'third' }
|
|
51
|
+
root.render(Image as any, { src: 'https://img/3.png', recyclingKey: 'c' })
|
|
52
|
+
expect(host.imageSource).toEqual({ bitmap: 'third' })
|
|
53
|
+
|
|
54
|
+
// No key at all — never blanks on src change.
|
|
55
|
+
root.render(Image as any, { src: 'https://img/4.png' })
|
|
56
|
+
expect(host.imageSource).toEqual({ bitmap: 'third' })
|
|
57
|
+
expect(host.src).toBe('https://img/4.png')
|
|
58
|
+
|
|
59
|
+
root.unmount()
|
|
60
|
+
})
|
|
61
|
+
})
|