@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
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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 =
|
|
155
|
+
const comma = srcString.indexOf(',');
|
|
18
156
|
if (
|
|
19
|
-
|
|
157
|
+
srcString.startsWith('data:') &&
|
|
20
158
|
comma > 0 &&
|
|
21
|
-
|
|
159
|
+
srcString.slice(0, comma).endsWith(';base64')
|
|
22
160
|
) {
|
|
23
|
-
return ImageSource.fromBase64Sync(
|
|
161
|
+
return ImageSource.fromBase64Sync(srcString.slice(comma + 1));
|
|
24
162
|
}
|
|
25
163
|
|
|
26
|
-
return
|
|
27
|
-
}, [
|
|
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=
|
|
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) =>
|
|
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
|
-
|
|
232
|
+
stretch={contentFitToStretch(fit)}
|
|
233
|
+
loadMode={props.decoding ?? 'async'}
|
|
48
234
|
accessibilityLabel={props.alt}
|
|
49
235
|
/>
|
|
50
236
|
}
|
package/src/Image.web.tsrx
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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={
|
|
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
|
}
|
package/src/Tabs.macos.tsrx
CHANGED
|
@@ -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}
|
package/src/Timestamp.macos.tsrx
CHANGED
package/src/colorScheme.macos.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useSyncExternalStore } from 'octane'
|
|
2
2
|
import type { ColorScheme } from './props'
|
|
3
3
|
|
|
4
|
-
let
|
|
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 (!
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
if (!unsubscribeAppearance) {
|
|
19
|
+
unsubscribeAppearance = host()?.onAppearanceChange?.(() => {
|
|
20
|
+
listeners.forEach((listener) => listener())
|
|
21
|
+
})
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
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
|
+
})
|