@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
package/src/Image.tsrx CHANGED
@@ -1,30 +1,182 @@
1
1
  /** @jsxImportSource @nativescript-community/octane */
2
- import { ImageSource } from '@nativescript/core';
3
- import { useMemo } from 'octane';
4
- import type { ImageProps } from './props';
2
+ import { isAndroid, isIOS, ImageSource, knownFolders, Screen } from '@nativescript/core';
3
+ import { useLayoutEffect, useMemo, useRef } from 'octane';
4
+ import type { ImageProps, ImageSourceLike } from './props';
5
5
  import { cx } from './cx';
6
6
  import { applyEscapeProps } from './escape-props';
7
+ import { rememberGridChildPlacement } from './grid-placement';
7
8
  import { layoutChildProps } from './layout-child';
8
9
  import { isSvgSrc, svgSource } from './svg';
10
+ import { createSizedImageBinding } from './image-sizing';
11
+ import { contentFitToStretch, needsContentRect, resolveContentFit, resolveContentPosition } from './image-content';
12
+ import { createImageContentBinding } from './image-content-binding';
13
+ import { hashToPngBase64 } from './hash-image';
14
+
15
+ /** Resolves `placeholder` to an ImageSource synchronously — a placeholder
16
+ * that must fetch first defeats its purpose, so remote URLs are ignored.
17
+ * Accepts `blurhash:`/`thumbhash:` (decoded to PNG bytes in JS — see
18
+ * ./hash-image), `data:` base64, `res://`, `~/`, and plain file paths. */
19
+ function placeholderSource(placeholder: string): ImageSource | null {
20
+ const png = hashToPngBase64(placeholder);
21
+ if (png) {
22
+ return ImageSource.fromBase64Sync(png);
23
+ }
24
+
25
+ const comma = placeholder.indexOf(',');
26
+ if (
27
+ placeholder.startsWith('data:') &&
28
+ comma > 0 &&
29
+ placeholder.slice(0, comma).endsWith(';base64')
30
+ ) {
31
+ return ImageSource.fromBase64Sync(placeholder.slice(comma + 1));
32
+ }
33
+
34
+ if (placeholder.startsWith('res://')) {
35
+ return ImageSource.fromResourceSync(placeholder.slice('res://'.length));
36
+ }
37
+
38
+ if (placeholder.startsWith('~/')) {
39
+ return ImageSource.fromFileSync(
40
+ knownFolders.currentApp().path + '/' + placeholder.slice(2),
41
+ );
42
+ }
43
+
44
+ if (!/^https?:\/\//.test(placeholder)) {
45
+ return ImageSource.fromFileSync(placeholder);
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ /** Re-resolves on every call — iOS disposes the previous ImageSource's
52
+ * native image each time `src` is assigned. Only writes when a load is
53
+ * pending (or nothing is showing), so a completed final image is never
54
+ * clobbered. */
55
+ function applyPlaceholder(view: any, placeholder: string | undefined): void {
56
+ if (!placeholder || !(view.isLoading || !view.imageSource)) {
57
+ return;
58
+ }
59
+
60
+ const ph = placeholderSource(placeholder);
61
+ if (ph) {
62
+ view.imageSource = ph;
63
+ }
64
+ }
65
+
9
66
  /** Image — NS <image>. `src` accepts the NS ImageSource grammar:
10
- * res://name, ~/bundle-path, file path, remote URL, or data: URI. SVG srcs
11
- * (inline markup, svg data URIs, .svg paths/URLs) route to <svgview> —
12
- * remote .svg URLs fetch to markup since SVGView has no fetch of its own.
13
- * `alt` maps to accessibilityLabel (RN calls it `alt` on web parity). */
67
+ * res://name, ~/bundle-path, file path, remote URL, or data: URI; an
68
+ * ImageSourceLike[] picks the closest pixel count to the laid-out view
69
+ * (srcset for native — study L8). SVG srcs (inline markup, svg data URIs,
70
+ * .svg paths/URLs) route to <svgview> — remote .svg URLs fetch to markup
71
+ * since SVGView has no fetch of its own. `alt` maps to accessibilityLabel
72
+ * (RN calls it `alt` on web parity).
73
+ * `contentFit` maps to stretch for the centered contain/cover/fill cases.
74
+ * 'none'/'scale-down'/positioned content takes the computed path: the
75
+ * binding measures the host box, computes the CSS content rect, and lays
76
+ * out an inner fill-stretched <image> inside a clipped absolutelayout —
77
+ * the view-level equivalent of expo's matrix (the stock NS widget owns
78
+ * its own draw matrix; see image-content-binding.ts).
79
+ * `src` never goes through JSX: the content binding resolves WHICH source
80
+ * (the array pick, or the prop as-is) and hands it to the sized binding,
81
+ * which holds it until first layout then sets decodeWidth/decodeHeight in
82
+ * device px before assigning — an eager src would decode at full source
83
+ * resolution on Android (study L1).
84
+ * `placeholder` rides the same view: NS nulls `imageSource` whenever a new
85
+ * `src` is applied, so the leaf (re)assigns the placeholder bitmap whenever
86
+ * the pipeline reports a pending load (`isLoadingChange`) and after every
87
+ * binding src write (`afterIssue` — a same-src re-issue mid-load fires no
88
+ * event). Placeholder → final inside one view; on the computed-rect path
89
+ * the inner stretch='fill' image scales it with the same content math, so
90
+ * fit/position match the final (expo-image study L5). A failed load leaves
91
+ * the placeholder standing — failure-image semantics for free.
92
+ * `decoding` maps to NS `loadMode` (default 'async' — the binding defers
93
+ * src to first layout, so loadMode is always on the view before src is
94
+ * issued); 'sync' blocks the UI thread at decode for must-paint-now
95
+ * bitmaps. Remote URLs decode async and font-icon srcs sync regardless —
96
+ * NS honors loadMode only on the file/resource/data paths. */
97
+ function clipImageHost(host: any): void {
98
+ host?.on?.('loaded', () => {
99
+ // iOS UIView clipsToBounds defaults false (DOM/NS overflow:visible);
100
+ // cover/positioned rects must clip at the component box. Android
101
+ // ViewGroups clip children by default — and the host never receives
102
+ // escape props, which would unclip it.
103
+ if (isIOS && host.ios) {
104
+ host.ios.clipsToBounds = true;
105
+ }
106
+ });
107
+ }
108
+
14
109
  export function Image(props: ImageProps) @{
15
- const svg = useMemo(() => (isSvgSrc(props.src) ? svgSource(props.src) : null), [props.src]);
110
+ const sources = Array.isArray(props.src) ? (props.src as ImageSourceLike[]) : null;
111
+ const srcString = sources ? '' : (props.src as string);
112
+ const fit = resolveContentFit(props.contentFit);
113
+ const position = resolveContentPosition(props.contentPosition);
114
+ const computed = needsContentRect(fit, position);
115
+ const imageRef = useRef<any>(null);
116
+ const placeholderRef = useRef(props.placeholder);
117
+ placeholderRef.current = props.placeholder;
118
+ const sizing = useMemo(
119
+ () =>
120
+ createSizedImageBinding({
121
+ afterIssue: (v: any) => applyPlaceholder(v, placeholderRef.current),
122
+ }),
123
+ [],
124
+ );
125
+
126
+ const binding = useMemo(
127
+ () =>
128
+ createImageContentBinding({
129
+ isAndroid,
130
+ screenScale: () => Screen.mainScreen?.scale ?? 1,
131
+ issue: (view: any, src: any, gate?: string) => sizing.update(view, src, gate),
132
+ }),
133
+ [],
134
+ );
135
+
136
+ const boundRef = useRef({ key: props.recyclingKey, src: props.src });
137
+ const bound = boundRef.current;
138
+ boundRef.current = { key: props.recyclingKey, src: props.src };
139
+ // expo-image `recyclingKey`: platform-list cells rebind the same view, and
140
+ // Android's setUri keeps drawing the previous bitmap until the new fetch
141
+ // resolves. Clear it before the ref re-binds the next src — only when
142
+ // identity AND source both changed (same src = same bitmap).
143
+ if (
144
+ bound.key != null &&
145
+ props.recyclingKey != null &&
146
+ bound.key !== props.recyclingKey &&
147
+ bound.src !== props.src &&
148
+ imageRef.current
149
+ ) {
150
+ imageRef.current.imageSource = null;
151
+ }
152
+
153
+ const svg = useMemo(() => (srcString && isSvgSrc(srcString) ? svgSource(srcString) : null), [srcString]);
16
154
  const source = useMemo(() => {
17
- const comma = props.src.indexOf(',');
155
+ const comma = srcString.indexOf(',');
18
156
  if (
19
- props.src.startsWith('data:') &&
157
+ srcString.startsWith('data:') &&
20
158
  comma > 0 &&
21
- props.src.slice(0, comma).endsWith(';base64')
159
+ srcString.slice(0, comma).endsWith(';base64')
22
160
  ) {
23
- return ImageSource.fromBase64Sync(props.src.slice(comma + 1));
161
+ return ImageSource.fromBase64Sync(srcString.slice(comma + 1));
24
162
  }
25
163
 
26
- return props.src;
27
- }, [props.src]);
164
+ return srcString;
165
+ }, [srcString]);
166
+
167
+ const update = { source: sources ? null : source, sources, fit, position, computed };
168
+
169
+ useLayoutEffect(() => {
170
+ const view = imageRef.current;
171
+ if (!view || !props.placeholder || svg != null) {
172
+ return;
173
+ }
174
+
175
+ const show = () => applyPlaceholder(view, props.placeholder);
176
+ show();
177
+ view.on?.('isLoadingChange', show);
178
+ return () => view.off?.('isLoadingChange', show);
179
+ }, [props.src, props.placeholder, svg]);
28
180
 
29
181
  @if (svg != null) {
30
182
  <svgview
@@ -34,17 +186,51 @@ export function Image(props: ImageProps) @{
34
186
  className={cx(props.className)}
35
187
  style={props.style}
36
188
  src={svg}
37
- stretch="aspectFit"
189
+ stretch={fit === 'none' ? 'none' : contentFitToStretch(fit === 'scale-down' ? 'contain' : fit)}
38
190
  accessibilityLabel={props.alt}
39
191
  />
192
+ } @else if (computed) {
193
+ <absolutelayout
194
+ ref={(v: any) => {
195
+ clipImageHost(v);
196
+ rememberGridChildPlacement(v, props);
197
+ if (v) {
198
+ binding.update(v, null, update);
199
+ }
200
+ }}
201
+ id={props.id}
202
+ {...layoutChildProps(props)}
203
+ className={cx(props.className)}
204
+ style={props.style}
205
+ >
206
+ <image
207
+ ref={(v: any) => {
208
+ imageRef.current = v;
209
+ applyEscapeProps(v, props);
210
+ if (v) {
211
+ binding.update(null, v, update);
212
+ }
213
+ }}
214
+ stretch="fill"
215
+ loadMode={props.decoding ?? 'async'}
216
+ accessibilityLabel={props.alt}
217
+ />
218
+ </absolutelayout>
40
219
  } @else {
41
220
  <image
42
- ref={(v: any) => applyEscapeProps(v, props)}
221
+ ref={(v: any) => {
222
+ imageRef.current = v;
223
+ applyEscapeProps(v, props);
224
+ if (v) {
225
+ binding.update(v, v, update);
226
+ }
227
+ }}
43
228
  id={props.id}
44
229
  {...layoutChildProps(props)}
45
230
  className={cx(props.className)}
46
231
  style={props.style}
47
- src={source}
232
+ stretch={contentFitToStretch(fit)}
233
+ loadMode={props.decoding ?? 'async'}
48
234
  accessibilityLabel={props.alt}
49
235
  />
50
236
  }
@@ -1,14 +1,100 @@
1
- import type { ImageProps } from "./props";
1
+ import type { ImageProps, ImageSourceLike } from "./props";
2
2
  import { applyEscapeProps } from "./escape-props";
3
- import { layoutChildProps } from './layout-child.web';
4
- /** Image — a plain <img>. `src` takes any URL or data: URI. */
3
+ import { layoutChildProps, normStyle } from './layout-child.web';
4
+ import { useLayoutEffect, useRef } from 'octane'
5
+ import { imageSrcSet, objectPositionCSSValue, resolveContentFit, resolveContentPosition } from './image-content';
6
+ import { hashToPngBase64 } from './hash-image'
7
+
8
+ /** `blurhash:`/`thumbhash:` decode to a data: PNG (./hash-image); every
9
+ * other value passes through as an ordinary <img> URL (a low-res source
10
+ * URL is a fine placeholder on web). */
11
+ function placeholderUrl(placeholder: string): string {
12
+ const png = hashToPngBase64(placeholder)
13
+ return png ? `data:image/png;base64,${png}` : placeholder
14
+ }
15
+
16
+ /** Image — a plain <img>. `src` takes any URL or data: URI; a source array
17
+ * becomes a real `srcset` (w descriptors from width·scale, x from scale —
18
+ * the browser owns selection, the same role bestImageSource plays on
19
+ * native). `contentFit`/`contentPosition` map 1:1 to object-fit/
20
+ * object-position; explicit props win over style, style wins over the
21
+ * 'cover'/'50% 50%' defaults. `placeholder` rides the same element: the
22
+ * img keeps showing its last decoded image while a new src fetches, so
23
+ * assigning placeholder → src swaps placeholder → final in one view —
24
+ * never a blank frame and no parallel loader (expo-image study L5). A
25
+ * hidden prefetch keeps the final fetch running while the placeholder
26
+ * paints. `decoding` writes the HTML `decoding` attribute 1:1 (default
27
+ * 'async' — the shared contract's default, not the browser's 'auto'). */
5
28
  export function Image(props: ImageProps) @{
29
+ const imgRef = useRef<HTMLImageElement | null>(null)
30
+ const sources = Array.isArray(props.src) ? (props.src as ImageSourceLike[]) : null;
31
+ const selected = sources
32
+ ? imageSrcSet(sources)
33
+ : { src: props.src as string, srcSet: undefined };
34
+
35
+ useLayoutEffect(() => {
36
+ const img = imgRef.current
37
+ // complete = the current src already resolved (cache/sync data URI) —
38
+ // showing the placeholder would flash backwards.
39
+ if (!img || !props.placeholder || img.complete) return
40
+ const placeholder = placeholderUrl(props.placeholder)
41
+ const { src: final, srcSet: finalSrcSet } = selected
42
+ // (document.createElement — `Image` here is this component, not the
43
+ // global HTMLImageElement constructor)
44
+ const warm = document.createElement('img')
45
+ warm.src = final
46
+ if (finalSrcSet) {
47
+ warm.srcset = finalSrcSet
48
+ }
49
+
50
+ const swap = () => {
51
+ img.src = final
52
+ if (finalSrcSet) {
53
+ // srcset overrides src while present — restore it after the
54
+ // src write so both point at the final selection.
55
+ img.srcset = finalSrcSet
56
+ }
57
+ }
58
+
59
+ img.addEventListener('load', swap, { once: true })
60
+ img.addEventListener('error', swap, { once: true })
61
+ if (finalSrcSet) {
62
+ // While srcset is set it wins over src — clear it or the
63
+ // placeholder assignment below is inert.
64
+ img.srcset = ''
65
+ }
66
+
67
+ img.src = placeholder
68
+ return () => {
69
+ img.removeEventListener('load', swap)
70
+ img.removeEventListener('error', swap)
71
+ }
72
+ }, [props.src, props.placeholder])
73
+
74
+ const style = {
75
+ objectFit: 'cover',
76
+ objectPosition: '50% 50%',
77
+ ...normStyle(props.style),
78
+ ...(props.contentFit !== undefined
79
+ ? { objectFit: resolveContentFit(props.contentFit) }
80
+ : null),
81
+ ...(props.contentPosition !== undefined
82
+ ? { objectPosition: objectPositionCSSValue(resolveContentPosition(props.contentPosition)) }
83
+ : null),
84
+ };
85
+
6
86
  <img
7
- ref={(e: any) => applyEscapeProps(e, props)}
87
+ ref={(e: any) => {
88
+ imgRef.current = e
89
+ applyEscapeProps(e, props)
90
+ }}
8
91
  id={props.id}
9
92
  className={props.className}
10
93
  {...layoutChildProps(props)}
11
- src={props.src}
94
+ src={selected.src}
95
+ srcSet={selected.srcSet}
96
+ decoding={props.decoding ?? 'async'}
12
97
  alt={props.alt}
98
+ style={style}
13
99
  />
14
100
  }
@@ -6,6 +6,8 @@ import { TabList, Tab } from './TabList.macos.tsrx'
6
6
  import { RouteHost } from './RouteHost.macos.tsrx'
7
7
  import { errorBoundaryFor, layoutsFor, useRoute, screenFor } from './route.macos'
8
8
 
9
+ export type { TabSpec } from './props'
10
+
9
11
  function screenParams(route: NonNullable<ReturnType<typeof useRoute>>) {
10
12
  const params: Record<string, unknown> = { ...route.params, ...(route.context ?? {}), _stack: route.stack, _pushed: true }
11
13
  if (Object.prototype.hasOwnProperty.call(route, 'loaderData')) {params.data = route.loaderData}
@@ -105,7 +105,7 @@ export function Timestamp(props: TimestampProps) @{
105
105
  ))}
106
106
  </flexboxlayout>
107
107
  }
108
- placement="top"
108
+ placement="above"
109
109
  >
110
110
  {labelNode}
111
111
  </HoverCard>
@@ -1,7 +1,7 @@
1
1
  import { useSyncExternalStore } from 'octane'
2
2
  import type { ColorScheme } from './props'
3
3
 
4
- let appearanceListener: (() => void) | undefined
4
+ let unsubscribeAppearance: (() => void) | undefined
5
5
  const listeners = new Set<() => void>()
6
6
  const host = () => (globalThis as any).__xplatAppKit
7
7
 
@@ -15,12 +15,25 @@ export function getColorScheme(): ColorScheme {
15
15
 
16
16
  export function subscribeSystemScheme(callback: () => void): () => void {
17
17
  listeners.add(callback)
18
- if (!appearanceListener) {
19
- appearanceListener = () => listeners.forEach((listener) => listener())
20
- host()?.onAppearanceChange?.(appearanceListener)
18
+ if (!unsubscribeAppearance) {
19
+ unsubscribeAppearance = host()?.onAppearanceChange?.(() => {
20
+ listeners.forEach((listener) => listener())
21
+ })
21
22
  }
22
23
 
23
- return () => listeners.delete(callback)
24
+ let active = true
25
+ return () => {
26
+ if (!active) {
27
+ return
28
+ }
29
+
30
+ active = false
31
+ listeners.delete(callback)
32
+ if (listeners.size === 0) {
33
+ unsubscribeAppearance?.()
34
+ unsubscribeAppearance = undefined
35
+ }
36
+ }
24
37
  }
25
38
 
26
39
  export function useColorScheme(): ColorScheme {
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { hashToPngBase64, isHashPlaceholder } from './hash-image'
3
+
4
+ const PNG_MAGIC = 'iVBORw0KGgo'
5
+
6
+ function pngBytes(base64: string) {
7
+ return Buffer.from(base64, 'base64')
8
+ }
9
+
10
+ function pngSize(base64: string) {
11
+ const bytes = pngBytes(base64)
12
+ return {
13
+ width: bytes.readUInt32BE(16),
14
+ height: bytes.readUInt32BE(20),
15
+ bitDepth: bytes[24],
16
+ colorType: bytes[25],
17
+ }
18
+ }
19
+
20
+ describe('isHashPlaceholder', () => {
21
+ it('accepts blurhash: and thumbhash: schemes only', () => {
22
+ expect(isHashPlaceholder('blurhash:LEHV6nWB2yk8pyo0adR*.7kCMdnj')).toBe(true)
23
+ expect(isHashPlaceholder('thumbhash:1QcSHQRnh493V4dIh4eXh1h4kJUI')).toBe(true)
24
+ expect(isHashPlaceholder('https://x/y.png')).toBe(false)
25
+ expect(isHashPlaceholder('res://icon')).toBe(false)
26
+ })
27
+ })
28
+
29
+ describe('hashToPngBase64', () => {
30
+ it('decodes a blurhash to a 32x32 RGBA PNG', () => {
31
+ const png = hashToPngBase64('blurhash:LEHV6nWB2yk8pyo0adR*.7kCMdnj')
32
+ expect(png).not.toBeNull()
33
+ expect(png!.startsWith(PNG_MAGIC)).toBe(true)
34
+ expect(pngSize(png!)).toEqual({ width: 32, height: 32, bitDepth: 8, colorType: 6 })
35
+ })
36
+
37
+ it('decodes a thumbhash to a bounded PNG', () => {
38
+ const png = hashToPngBase64('thumbhash:1QcSHQRnh493V4dIh4eXh1h4kJUI')
39
+ expect(png).not.toBeNull()
40
+ expect(png!.startsWith(PNG_MAGIC)).toBe(true)
41
+ const size = pngSize(png!)
42
+ expect(size.width).toBeGreaterThan(0)
43
+ expect(size.width).toBeLessThanOrEqual(32)
44
+ expect(size.height).toBeGreaterThan(0)
45
+ expect(size.height).toBeLessThanOrEqual(32)
46
+ })
47
+
48
+ it('produces non-uniform pixel data (real decode, not a flat fill)', () => {
49
+ const png = hashToPngBase64('blurhash:LGF5]+Yk^6#M@-5c,1J5@[or[Q6.')
50
+ const bytes = pngBytes(png!)
51
+ const unique = new Set(bytes.subarray(60))
52
+ expect(unique.size).toBeGreaterThan(8)
53
+ })
54
+
55
+ it('is deterministic', () => {
56
+ const src = 'blurhash:LEHV6nWB2yk8pyo0adR*.7kCMdnj'
57
+ expect(hashToPngBase64(src)).toBe(hashToPngBase64(src))
58
+ })
59
+
60
+ it.each([
61
+ 'blurhash:',
62
+ 'blurhash:abc',
63
+ 'blurhash:!!!!!!!!!!!!!!!!!',
64
+ 'thumbhash:',
65
+ 'thumbhash:%%%',
66
+ 'thumbhash:AAAA',
67
+ 'https://x/y.png',
68
+ 'plain string',
69
+ ])('returns null for %s', (src) => {
70
+ expect(hashToPngBase64(src)).toBeNull()
71
+ })
72
+ })