@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.
Files changed (221) hide show
  1. package/dist/native/Image.tsrx.js +151 -16
  2. package/dist/native/hash-image.js +285 -0
  3. package/dist/native/image-content-binding.js +158 -0
  4. package/dist/native/image-content.js +206 -0
  5. package/dist/native/image-sizing.js +102 -0
  6. package/dist/native/octane-client-build.json +1 -1
  7. package/dist/native/vendor/ui-svg/index.android.js +1 -1
  8. package/dist/web/Image.web.tsrx.js +112 -19
  9. package/dist/web/hash-image.js +285 -0
  10. package/dist/web/image-content.js +133 -0
  11. package/dist/web/octane-client-build.json +1 -1
  12. package/package.json +5 -4
  13. package/src/Image.macos.tsrx +11 -2
  14. package/src/Image.tsrx +203 -17
  15. package/src/Image.web.tsrx +91 -5
  16. package/src/Tabs.macos.tsrx +2 -0
  17. package/src/Timestamp.macos.tsrx +1 -1
  18. package/src/colorScheme.macos.ts +18 -5
  19. package/src/hash-image.test.ts +72 -0
  20. package/src/hash-image.ts +310 -0
  21. package/src/hosted-unsupported.macos.tsrx +1 -2
  22. package/src/image-content-binding.mobile.test.ts +174 -0
  23. package/src/image-content-binding.ts +231 -0
  24. package/src/image-content.mobile.test.tsrx +84 -0
  25. package/src/image-content.test.ts +219 -0
  26. package/src/image-content.ts +309 -0
  27. package/src/image-decoding.web.test.tsrx +26 -0
  28. package/src/image-placeholder.mobile.test.tsrx +113 -0
  29. package/src/image-placeholder.web.test.tsrx +48 -0
  30. package/src/image-recycling.mobile.test.ts +61 -0
  31. package/src/image-sizing.mobile.test.ts +158 -0
  32. package/src/image-sizing.ts +117 -0
  33. package/src/image.mobile.test.tsrx +81 -0
  34. package/src/index.macos.ts +1 -0
  35. package/src/index.shared.ts +7 -0
  36. package/src/index.shared.web.ts +7 -0
  37. package/src/list-viewability-observer.ios.ts +1 -0
  38. package/src/list-viewability.mobile.test.ts +1 -0
  39. package/src/props.ts +100 -1
  40. package/src/safeAreaInsets.macos.tsrx +6 -1
  41. package/src/styled.macos.tsrx +1 -1
  42. package/src/text-controls.macos.tsrx +3 -1
  43. package/src/vendor/ui-svg/src/index.android.ts +5 -1
  44. package/types/generated/.tsrx-typegen-manifest.json +170 -0
  45. package/types/generated/Accordion.macos.d.ts +5 -0
  46. package/types/generated/Alert.macos.d.ts +3 -0
  47. package/types/generated/AlertDialog.macos.d.ts +4 -0
  48. package/types/generated/AppShell.macos.d.ts +7 -0
  49. package/types/generated/AspectRatio.macos.d.ts +5 -0
  50. package/types/generated/Avatar.macos.d.ts +6 -0
  51. package/types/generated/AvatarGroup.macos.d.ts +6 -0
  52. package/types/generated/Badge.macos.d.ts +5 -0
  53. package/types/generated/Banner.macos.d.ts +4 -0
  54. package/types/generated/BaseTypeahead.macos.d.ts +7 -0
  55. package/types/generated/Blockquote.macos.d.ts +6 -0
  56. package/types/generated/BottomSheet.macos.d.ts +3 -0
  57. package/types/generated/Breadcrumb.macos.d.ts +4 -0
  58. package/types/generated/ButtonGroup.macos.d.ts +3 -0
  59. package/types/generated/Calendar.macos.d.ts +5 -0
  60. package/types/generated/Card.macos.d.ts +4 -0
  61. package/types/generated/Carousel.macos.d.ts +6 -0
  62. package/types/generated/Center.macos.d.ts +4 -0
  63. package/types/generated/CheckIndicator.macos.d.ts +4 -0
  64. package/types/generated/CheckboxGroup.macos.d.ts +3 -0
  65. package/types/generated/CheckboxIndicator.macos.d.ts +4 -0
  66. package/types/generated/Chip.macos.d.ts +5 -0
  67. package/types/generated/Citation.macos.d.ts +4 -0
  68. package/types/generated/ClickableCard.macos.d.ts +3 -0
  69. package/types/generated/Code.macos.d.ts +5 -0
  70. package/types/generated/CodeBlock.macos.d.ts +6 -0
  71. package/types/generated/Collapsible.macos.d.ts +5 -0
  72. package/types/generated/CommandPalette.macos.d.ts +3 -0
  73. package/types/generated/ComplexSelector.macos.d.ts +3 -0
  74. package/types/generated/ContextMenu.macos.d.ts +5 -0
  75. package/types/generated/DateInput.macos.d.ts +6 -0
  76. package/types/generated/DateRangeInput.macos.d.ts +5 -0
  77. package/types/generated/DateTimeInput.macos.d.ts +5 -0
  78. package/types/generated/Dialog.macos.d.ts +3 -0
  79. package/types/generated/Drawer.macos.d.ts +7 -0
  80. package/types/generated/DropdownMenu.macos.d.ts +5 -0
  81. package/types/generated/Empty.macos.d.ts +4 -0
  82. package/types/generated/FieldGroup.macos.d.ts +4 -0
  83. package/types/generated/FormField.macos.d.ts +4 -0
  84. package/types/generated/FormLayout.macos.d.ts +6 -0
  85. package/types/generated/HStack.macos.d.ts +4 -0
  86. package/types/generated/Heading.macos.d.ts +3 -0
  87. package/types/generated/Icon.macos.d.ts +3 -0
  88. package/types/generated/IconButton.macos.d.ts +3 -0
  89. package/types/generated/Image.d.ts +0 -5
  90. package/types/generated/Image.macos.d.ts +2 -0
  91. package/types/generated/Image.web.d.ts +12 -1
  92. package/types/generated/InputGroup.macos.d.ts +5 -0
  93. package/types/generated/InputNumber.macos.d.ts +5 -0
  94. package/types/generated/InputRating.macos.d.ts +4 -0
  95. package/types/generated/Item.macos.d.ts +3 -0
  96. package/types/generated/Kbd.macos.d.ts +4 -0
  97. package/types/generated/KeyboardAvoiding.macos.d.ts +3 -0
  98. package/types/generated/Lightbox.macos.d.ts +2 -0
  99. package/types/generated/Link.macos.d.ts +3 -0
  100. package/types/generated/List.macos.d.ts +4 -0
  101. package/types/generated/ListItem.macos.d.ts +5 -0
  102. package/types/generated/MenuParts.macos.d.ts +9 -0
  103. package/types/generated/MenuSurface.macos.d.ts +4 -0
  104. package/types/generated/MetadataList.macos.d.ts +4 -0
  105. package/types/generated/MetadataListItem.macos.d.ts +3 -0
  106. package/types/generated/Meter.macos.d.ts +3 -0
  107. package/types/generated/MobileNav.macos.d.ts +15 -0
  108. package/types/generated/MoreMenu.macos.d.ts +2 -0
  109. package/types/generated/NavIcon.macos.d.ts +4 -0
  110. package/types/generated/NavLink.macos.d.ts +3 -0
  111. package/types/generated/NavMenu.macos.d.ts +11 -0
  112. package/types/generated/NavigationMenu.macos.d.ts +4 -0
  113. package/types/generated/Outline.macos.d.ts +5 -0
  114. package/types/generated/OverflowList.macos.d.ts +6 -0
  115. package/types/generated/Overlay.macos.d.ts +3 -0
  116. package/types/generated/Pagination.macos.d.ts +4 -0
  117. package/types/generated/PinInput.macos.d.ts +4 -0
  118. package/types/generated/Popover.macos.d.ts +3 -0
  119. package/types/generated/PowerSearch.macos.d.ts +7 -0
  120. package/types/generated/PowerSearchFilterEditor.macos.d.ts +12 -0
  121. package/types/generated/PowerSearchToken.macos.d.ts +7 -0
  122. package/types/generated/Pressable.macos.d.ts +2 -0
  123. package/types/generated/ProgressBar.macos.d.ts +6 -0
  124. package/types/generated/ProgressGroup.macos.d.ts +3 -0
  125. package/types/generated/RadioGroup.macos.d.ts +5 -0
  126. package/types/generated/RadioIndicator.macos.d.ts +3 -0
  127. package/types/generated/ResizeHandle.macos.d.ts +7 -0
  128. package/types/generated/RouteBoundary.macos.d.ts +8 -0
  129. package/types/generated/RouteHost.macos.d.ts +13 -0
  130. package/types/generated/Row.macos.d.ts +2 -0
  131. package/types/generated/SafeArea.macos.d.ts +3 -0
  132. package/types/generated/Screen.macos.d.ts +3 -0
  133. package/types/generated/ScrollableArea.macos.d.ts +7 -0
  134. package/types/generated/Section.macos.d.ts +5 -0
  135. package/types/generated/Select.macos.d.ts +3 -0
  136. package/types/generated/SelectableCard.macos.d.ts +3 -0
  137. package/types/generated/Separator.macos.d.ts +5 -0
  138. package/types/generated/SideNav.macos.d.ts +19 -0
  139. package/types/generated/Skeleton.macos.d.ts +5 -0
  140. package/types/generated/StatusDot.macos.d.ts +5 -0
  141. package/types/generated/Stepper.macos.d.ts +4 -0
  142. package/types/generated/TabList.macos.d.ts +18 -0
  143. package/types/generated/Table.macos.d.ts +4 -0
  144. package/types/generated/Tabs.macos.d.ts +3 -0
  145. package/types/generated/Text.macos.d.ts +5 -0
  146. package/types/generated/Thumbnail.macos.d.ts +7 -0
  147. package/types/generated/TimeInput.macos.d.ts +6 -0
  148. package/types/generated/TimeWheelPanel.macos.d.ts +16 -0
  149. package/types/generated/Timeline.macos.d.ts +3 -0
  150. package/types/generated/Timer.macos.d.ts +3 -0
  151. package/types/generated/Timestamp.macos.d.ts +5 -0
  152. package/types/generated/Toast.macos.d.ts +3 -0
  153. package/types/generated/ToggleButton.macos.d.ts +3 -0
  154. package/types/generated/Token.macos.d.ts +5 -0
  155. package/types/generated/Tokenizer.macos.d.ts +6 -0
  156. package/types/generated/Toolbar.macos.d.ts +6 -0
  157. package/types/generated/TopNav.macos.d.ts +25 -0
  158. package/types/generated/Tree.macos.d.ts +4 -0
  159. package/types/generated/Typeahead.macos.d.ts +8 -0
  160. package/types/generated/TypeaheadItem.macos.d.ts +5 -0
  161. package/types/generated/User.macos.d.ts +5 -0
  162. package/types/generated/VStack.macos.d.ts +4 -0
  163. package/types/generated/View.macos.d.ts +3 -0
  164. package/types/generated/VirtualList.macos.d.ts +6 -0
  165. package/types/generated/VisuallyHidden.macos.d.ts +5 -0
  166. package/types/generated/WebView.macos.d.ts +2 -0
  167. package/types/generated/aliases.macos.d.ts +3 -0
  168. package/types/generated/anim.macos.d.ts +3 -0
  169. package/types/generated/animation-host.macos.d.ts +3 -0
  170. package/types/generated/bottom-sheet-panel.macos.d.ts +7 -0
  171. package/types/generated/bottom-sheet-service.macos.d.ts +5 -0
  172. package/types/generated/clipboard-write.macos.d.ts +3 -0
  173. package/types/generated/colorScheme.macos.d.ts +4 -0
  174. package/types/generated/command-palette-keys.macos.d.ts +2 -0
  175. package/types/generated/command-palette-scroll.macos.d.ts +2 -0
  176. package/types/generated/controls.macos.d.ts +8 -0
  177. package/types/generated/date-composition.macos.d.ts +5 -0
  178. package/types/generated/escape-props.macos.d.ts +5 -0
  179. package/types/generated/hash-image.d.ts +13 -0
  180. package/types/generated/hosted-unsupported.macos.d.ts +1 -0
  181. package/types/generated/image-content-binding.d.ts +64 -0
  182. package/types/generated/image-content.d.ts +56 -0
  183. package/types/generated/image-sizing.d.ts +37 -0
  184. package/types/generated/index.macos.d.ts +196 -0
  185. package/types/generated/index.shared.d.ts +1 -1
  186. package/types/generated/index.shared.web.d.ts +1 -1
  187. package/types/generated/layout-shells.macos.d.ts +3 -0
  188. package/types/generated/layout.macos.d.ts +9 -0
  189. package/types/generated/macos-extras.macos.d.ts +4 -0
  190. package/types/generated/measure-bounds.macos.d.ts +3 -0
  191. package/types/generated/nav-href.macos.d.ts +4 -0
  192. package/types/generated/outline-dom.macos.d.ts +2 -0
  193. package/types/generated/pan.macos.d.ts +3 -0
  194. package/types/generated/platform.macos.d.ts +1 -0
  195. package/types/generated/presentation.macos.d.ts +15 -0
  196. package/types/generated/props.d.ts +76 -1
  197. package/types/generated/query-storage.macos.d.ts +8 -0
  198. package/types/generated/resize-basis.macos.d.ts +4 -0
  199. package/types/generated/resize-persist.macos.d.ts +8 -0
  200. package/types/generated/route.macos.d.ts +28 -0
  201. package/types/generated/safeAreaInsets.macos.d.ts +10 -0
  202. package/types/generated/stacks.macos.d.ts +3 -0
  203. package/types/generated/styled.macos.d.ts +9 -0
  204. package/types/generated/surfaces.macos.d.ts +4 -0
  205. package/types/generated/svg-image.macos.d.ts +3 -0
  206. package/types/generated/text-controls.macos.d.ts +4 -0
  207. package/types/generated/theme/theme-scheme.macos.d.ts +8 -0
  208. package/types/generated/toast-service.macos.d.ts +3 -0
  209. package/types/generated/toast-viewport.macos.d.ts +5 -0
  210. package/types/generated/translate.macos.d.ts +1 -0
  211. package/types/generated/unsupported.macos.d.ts +1 -0
  212. package/types/generated/use-imperative-alert-dialog.macos.d.ts +2 -0
  213. package/types/generated/use-imperative-dialog.macos.d.ts +2 -0
  214. package/types/generated/use-lightbox.macos.d.ts +2 -0
  215. package/types/generated/use-scrollable-area.macos.d.ts +6 -0
  216. package/types/generated/useMeasure.macos.d.ts +3 -0
  217. package/types/generated/useResizable.macos.d.ts +8 -0
  218. package/types/generated/useStore.macos.d.ts +2 -0
  219. package/types/generated/windows.macos.d.ts +2 -0
  220. package/src/CameraView.macos.tsrx +0 -6
  221. 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
+ })