ink-framework 0.0.0-stage → 0.1.0-alpha.2
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/LICENSE +21 -0
- package/ink-native.json +33 -0
- package/package.json +37 -4
- package/runtime.d.ts +81 -0
- package/src/action.ts +49 -0
- package/src/appearance.ts +24 -0
- package/src/assets.d.ts +45 -0
- package/src/assets.ts +7 -0
- package/src/canvas.ts +40 -0
- package/src/controller.ts +43 -0
- package/src/conversation.ts +205 -0
- package/src/external.ts +13 -0
- package/src/file-router.tsx +74 -0
- package/src/icons/conversation/LICENSE.md +21 -0
- package/src/icons/conversation/tick-double.svg +1 -0
- package/src/icons/conversation/tick.svg +1 -0
- package/src/icons/conversation/view.svg +4 -0
- package/src/icons.ts +8509 -0
- package/src/image.ts +45 -0
- package/src/index.ts +137 -0
- package/src/input.ts +24 -0
- package/src/link-preview.ts +29 -0
- package/src/links.ts +13 -0
- package/src/list-patch.ts +14 -0
- package/src/list.ts +134 -0
- package/src/native-list.ts +248 -0
- package/src/native.ts +96 -0
- package/src/navigation.ts +237 -0
- package/src/patterns.ts +73 -0
- package/src/playback-binding.ts +1 -0
- package/src/playing.ts +79 -0
- package/src/press.ts +16 -0
- package/src/react.ts +6 -0
- package/src/renderer.ts +341 -0
- package/src/reorder.ts +49 -0
- package/src/resource.ts +134 -0
- package/src/route-path.ts +38 -0
- package/src/scroll.ts +3 -0
- package/src/snapshot.ts +18 -0
- package/tsconfig.app.json +13 -0
- package/tsconfig.json +21 -0
- package/README.md +0 -3
package/src/image.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { IconAsset } from "./index";
|
|
2
|
+
import { createElement, memo } from "./react";
|
|
3
|
+
import type { Destination } from "./navigation";
|
|
4
|
+
import { pressHandler, type PressProps } from "./press";
|
|
5
|
+
|
|
6
|
+
export function Avatar({ src = "", unread = false, onPress, onLongPress }: {
|
|
7
|
+
src?: string;
|
|
8
|
+
unread?: boolean;
|
|
9
|
+
onPress?: () => void;
|
|
10
|
+
onLongPress?: () => void;
|
|
11
|
+
}) {
|
|
12
|
+
return createElement("Avatar", { src, unread, onPress, onLongPress });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function RowComponent<const D extends Destination>({ image, title, titleMaxLines, titleIcon, subtitle, subtitleIcon, href, url, onPress, onLongPress }: PressProps<D> & {
|
|
16
|
+
image?: string;
|
|
17
|
+
title: string;
|
|
18
|
+
titleMaxLines?: number;
|
|
19
|
+
titleIcon?: IconAsset;
|
|
20
|
+
subtitle?: string;
|
|
21
|
+
subtitleIcon?: IconAsset;
|
|
22
|
+
onLongPress?: () => void;
|
|
23
|
+
}) {
|
|
24
|
+
return createElement("RowContent", {
|
|
25
|
+
image, title, titleMaxLines, titleIcon, subtitle, subtitleIcon,
|
|
26
|
+
onPress: pressHandler({ href, url, onPress }) ?? (onLongPress ? () => {} : undefined),
|
|
27
|
+
onLongPress,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const Row = /* @__PURE__ */ memo(RowComponent) as typeof RowComponent;
|
|
32
|
+
|
|
33
|
+
export function Image(props: {
|
|
34
|
+
src: string;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
fit?: "contain" | "cover";
|
|
38
|
+
bleed?: boolean;
|
|
39
|
+
/** Fill the available content width, using width and height as the aspect ratio. */
|
|
40
|
+
fillWidth?: boolean;
|
|
41
|
+
zoomable?: boolean;
|
|
42
|
+
loop?: boolean;
|
|
43
|
+
}) {
|
|
44
|
+
return createElement("Image", props);
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/// <reference path="./assets.d.ts" />
|
|
2
|
+
import type { IconAsset } from "./assets";
|
|
3
|
+
import type { ReactElement, ReactNode } from "react";
|
|
4
|
+
import type {} from "react/jsx-runtime";
|
|
5
|
+
import type {} from "react/jsx-dev-runtime";
|
|
6
|
+
import { createElement, useMemo, useState } from "./react";
|
|
7
|
+
import { ScrollToEndContext } from "./scroll";
|
|
8
|
+
import type { Destination } from "./navigation";
|
|
9
|
+
import { pressHandler, type PressProps } from "./press";
|
|
10
|
+
export { useSnapshot, type Snapshot, type SnapshotSource } from "./snapshot";
|
|
11
|
+
export { resource, type ResourceSnapshot, type ResourceSource } from "./resource";
|
|
12
|
+
export { useAction, type Action } from "./action";
|
|
13
|
+
export { NativeError } from "./native";
|
|
14
|
+
export { Tabs, Slot, navigate, replace, back, useRouteParams, type Destination } from "./navigation";
|
|
15
|
+
|
|
16
|
+
export function Screen({ header, children, leftAction, rightAction, ...props }: {
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
header?: ReactNode;
|
|
19
|
+
title?: string;
|
|
20
|
+
background?: string;
|
|
21
|
+
centered?: boolean;
|
|
22
|
+
wide?: boolean;
|
|
23
|
+
bottomInset?: boolean;
|
|
24
|
+
waitForImages?: boolean;
|
|
25
|
+
leftAction?: { icon: IconAsset; onPress: () => void };
|
|
26
|
+
rightAction?: { icon: IconAsset; onPress: () => void };
|
|
27
|
+
}) {
|
|
28
|
+
const [scrollToEnd, setScrollToEnd] = useState(0);
|
|
29
|
+
const requestEnd = useMemo(() => () => setScrollToEnd(value => value + 1), []);
|
|
30
|
+
return createElement(ScrollToEndContext.Provider, { value: requestEnd }, createElement("Screen", { ...props, scrollToEnd, pinnedHeader: header != null, leftIcon: leftAction?.icon, onLeftPress: leftAction?.onPress, rightIcon: rightAction?.icon, onRightPress: rightAction?.onPress },
|
|
31
|
+
header != null && createElement(Stack, { gap: 47 }, header),
|
|
32
|
+
children,
|
|
33
|
+
));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface StackProps {
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
axis?: "vertical" | "horizontal";
|
|
39
|
+
gap?: number;
|
|
40
|
+
align?: "start" | "center" | "end" | "stretch";
|
|
41
|
+
justify?: "start" | "center" | "end" | "space-between";
|
|
42
|
+
onPress?: () => void;
|
|
43
|
+
haptic?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const Stack = "Stack";
|
|
47
|
+
|
|
48
|
+
export type TextProps<D extends Destination = Destination> = PressProps<D> & {
|
|
49
|
+
width?: number;
|
|
50
|
+
children?: ReactNode;
|
|
51
|
+
size?: number;
|
|
52
|
+
align?: "start" | "center" | "end" | "justify";
|
|
53
|
+
maxLines?: number;
|
|
54
|
+
tabularNumbers?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Keep text as a host element while checking literal route parameters in JSX.
|
|
58
|
+
export const Text = "Text" as "Text" & (<const D extends Destination>(props: TextProps<D>) => ReactElement);
|
|
59
|
+
|
|
60
|
+
declare module "react" {
|
|
61
|
+
namespace JSX {
|
|
62
|
+
interface IntrinsicElements {
|
|
63
|
+
Text: TextProps;
|
|
64
|
+
Stack: StackProps;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The automatic JSX runtime can resolve a different React type instance in linked apps.
|
|
70
|
+
declare module "react/jsx-runtime" {
|
|
71
|
+
namespace JSX {
|
|
72
|
+
interface IntrinsicElements { Text: TextProps; Stack: StackProps; }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
declare module "react/jsx-dev-runtime" {
|
|
77
|
+
namespace JSX {
|
|
78
|
+
interface IntrinsicElements { Text: TextProps; Stack: StackProps; }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function Button<const D extends Destination>(props: PressProps<D> & {
|
|
83
|
+
children?: ReactNode;
|
|
84
|
+
onLongPress?: () => void;
|
|
85
|
+
disabled?: boolean;
|
|
86
|
+
selected?: boolean;
|
|
87
|
+
icon?: IconAsset;
|
|
88
|
+
}) {
|
|
89
|
+
const { href, url, onPress, ...rest } = props;
|
|
90
|
+
return createElement("Button", { ...rest, onPress: pressHandler({ href, url, onPress }) });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { Row, Image, Avatar } from "./image";
|
|
94
|
+
export { Canvas, Rectangle, CanvasText, CanvasIcon } from "./canvas";
|
|
95
|
+
|
|
96
|
+
export function Field<const D extends Destination>(props: PressProps<D> & {
|
|
97
|
+
label: string;
|
|
98
|
+
children?: ReactNode;
|
|
99
|
+
}) {
|
|
100
|
+
const { href, url, onPress, ...rest } = props;
|
|
101
|
+
return createElement("Field", { ...rest, onPress: pressHandler({ href, url, onPress }) });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function Toggle(props: {
|
|
105
|
+
label: string;
|
|
106
|
+
subtitle?: string;
|
|
107
|
+
value: boolean;
|
|
108
|
+
disabled?: boolean;
|
|
109
|
+
onChange: (value: boolean) => void;
|
|
110
|
+
}) {
|
|
111
|
+
return createElement("Toggle", props);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function Icon(props: {
|
|
115
|
+
name: IconAsset;
|
|
116
|
+
size?: number;
|
|
117
|
+
tone?: "primary" | "muted";
|
|
118
|
+
}) {
|
|
119
|
+
return createElement("Icon", props);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { TextInput } from "./input";
|
|
123
|
+
|
|
124
|
+
export { List, type ListProps } from "./list";
|
|
125
|
+
export { ReorderList, type ReorderListProps } from "./reorder";
|
|
126
|
+
export { setColourScheme, useColourScheme, type ColourScheme } from "./appearance";
|
|
127
|
+
export {
|
|
128
|
+
SettingsChoices, LoadingState, EmptyState, ErrorState, Confirmation,
|
|
129
|
+
type SettingsChoice, type SettingsChoicesProps, type EmptyStateProps,
|
|
130
|
+
type ErrorStateProps, type ConfirmationProps,
|
|
131
|
+
} from "./patterns";
|
|
132
|
+
|
|
133
|
+
export { findIcon, type IconAsset } from "./assets";
|
|
134
|
+
export { PlayingScreen, type PlayingScreenProps, type Playback, type TransportControl } from "./playing";
|
|
135
|
+
export { ConversationScreen, Message, type ConversationScreenProps, type ConversationMessage, type MessageProps, type MessageAction, type ReplyPreview } from "./conversation";
|
|
136
|
+
export { openURL, share } from "./external";
|
|
137
|
+
export { LinkPreview, type LinkPreviewData, type LinkPreviewProps } from "./link-preview";
|
package/src/input.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createElement, useState } from "./react";
|
|
2
|
+
|
|
3
|
+
export function TextInput(props: {
|
|
4
|
+
value: string;
|
|
5
|
+
onChange: (value: string) => void;
|
|
6
|
+
onSubmit?: (value: string) => void;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
autoFocus?: boolean;
|
|
9
|
+
action?: "return" | "search" | "done";
|
|
10
|
+
inputMode?: "text" | "numeric";
|
|
11
|
+
prefix?: string;
|
|
12
|
+
suffix?: string;
|
|
13
|
+
}) {
|
|
14
|
+
const [eventCount, setEventCount] = useState(0);
|
|
15
|
+
const nativeProps = {
|
|
16
|
+
...props,
|
|
17
|
+
eventCount,
|
|
18
|
+
onChange(value: string, count: number) {
|
|
19
|
+
setEventCount(count);
|
|
20
|
+
props.onChange(value);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
return createElement("TextInput", nativeProps);
|
|
24
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createElement } from "./react";
|
|
2
|
+
import { Image, Stack, Text } from "./index";
|
|
3
|
+
import { openLink } from "./external";
|
|
4
|
+
|
|
5
|
+
export type LinkPreviewData = {
|
|
6
|
+
url: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
image?: { src: string; width: number; height: number };
|
|
9
|
+
icon?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type LinkPreviewProps = LinkPreviewData & {
|
|
13
|
+
onPress?: () => void;
|
|
14
|
+
onLongPress?: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function LinkPreview({ url, title, image, icon, onPress, onLongPress }: LinkPreviewProps) {
|
|
18
|
+
const domain = url.replace(/^https?:\/\/(?:www\.)?/, "").split(/[/?#]/, 1)[0];
|
|
19
|
+
return createElement("Pressable", { onPress: onPress ?? (() => openLink(url)), onLongPress },
|
|
20
|
+
createElement("LinkPreview", null,
|
|
21
|
+
image && createElement(Image, {
|
|
22
|
+
src: image.src, width: 220, height: 220 * image.height / image.width, fit: "cover",
|
|
23
|
+
}),
|
|
24
|
+
createElement(Stack, { gap: 4, align: "stretch" },
|
|
25
|
+
title && createElement(Text, { size: 16, maxLines: 2 }, title),
|
|
26
|
+
createElement(Stack, { axis: "horizontal", gap: 8, align: "center" },
|
|
27
|
+
icon && createElement(Image, { src: icon, width: 16, height: 16, fit: "contain" }),
|
|
28
|
+
createElement(Text, { size: 13, maxLines: 1 }, domain)))));
|
|
29
|
+
}
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LinkifyIt } from "linkify-it";
|
|
2
|
+
|
|
3
|
+
let linkify: LinkifyIt | undefined;
|
|
4
|
+
|
|
5
|
+
export function textLinks(text: string) {
|
|
6
|
+
linkify ??= new LinkifyIt({ fuzzyLink: true });
|
|
7
|
+
return (linkify.match(text) || []).map(match => ({
|
|
8
|
+
start: match.index,
|
|
9
|
+
end: match.lastIndex,
|
|
10
|
+
url: !match.schema
|
|
11
|
+
? match.url.replace(/^http:/, "https:") : match.url,
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type NativeListItem = { key: string; values: unknown; measurementKey: string | number | null };
|
|
2
|
+
type Patch = { previous: NativeListItem[]; changes: NativeListItem[]; keys?: string[] };
|
|
3
|
+
|
|
4
|
+
const patches = new WeakMap<NativeListItem[], Patch>();
|
|
5
|
+
|
|
6
|
+
export function rememberListPatch(items: NativeListItem[], patch: Patch) {
|
|
7
|
+
patches.set(items, patch);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function listPatch(previous: NativeListItem[], items: NativeListItem[]) {
|
|
11
|
+
const patch = patches.get(items);
|
|
12
|
+
patches.delete(items);
|
|
13
|
+
return patch?.previous === previous ? patch : undefined;
|
|
14
|
+
}
|
package/src/list.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createElement, Fragment, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "./react";
|
|
2
|
+
|
|
3
|
+
import { useAction } from "./action";
|
|
4
|
+
import { Button, Stack, Text } from "./index";
|
|
5
|
+
import { CompiledList, nativeListPlan } from "./native-list";
|
|
6
|
+
import { ScrollToEndContext } from "./scroll";
|
|
7
|
+
|
|
8
|
+
const OLDER_PAGE_AHEAD_ITEMS = 8;
|
|
9
|
+
|
|
10
|
+
export function ListLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
|
11
|
+
return createElement(Stack, { gap: 24 },
|
|
12
|
+
createElement(Text, { size: 18 }, message),
|
|
13
|
+
createElement(Button, { onPress: onRetry }, "Try again"),
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ListProps<T> = {
|
|
18
|
+
items: readonly T[];
|
|
19
|
+
keyExtractor: (item: T, index: number) => string;
|
|
20
|
+
renderItem: (item: T, index: number) => ReactNode;
|
|
21
|
+
gap?: number;
|
|
22
|
+
followEnd?: boolean;
|
|
23
|
+
measurementKey?: string | number;
|
|
24
|
+
onLoadMore?: () => Promise<void>;
|
|
25
|
+
hasMore?: boolean;
|
|
26
|
+
onLoadOlder?: () => Promise<void>;
|
|
27
|
+
hasOlder?: boolean;
|
|
28
|
+
initialPosition?: "start" | "end";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function List<T>(props: ListProps<T>) {
|
|
32
|
+
const requestEnd = useContext(ScrollToEndContext);
|
|
33
|
+
const initialised = useRef(false);
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
if (initialised.current || props.initialPosition === "end" && !props.items.length) return;
|
|
36
|
+
initialised.current = true;
|
|
37
|
+
if (props.initialPosition === "end") requestEnd?.();
|
|
38
|
+
}, [props.initialPosition, props.items.length, requestEnd]);
|
|
39
|
+
const plan = nativeListPlan(props.renderItem);
|
|
40
|
+
return plan ? createElement(CompiledList<T>, { ...props, plan, key: plan.template }) : createElement(ReactList<T>, props);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, followEnd = false, measurementKey, onLoadMore, hasMore = false, onLoadOlder, hasOlder = false, initialPosition = "start" }: ListProps<T>) {
|
|
44
|
+
if (!Number.isFinite(gap) || gap < 0) {
|
|
45
|
+
throw new Error("List gap must be finite and non-negative");
|
|
46
|
+
}
|
|
47
|
+
const initialWindow = useRef(initialPosition === "end");
|
|
48
|
+
const previous = useRef<{
|
|
49
|
+
measurementKey?: string | number;
|
|
50
|
+
keyExtractor: ListProps<T>["keyExtractor"];
|
|
51
|
+
items: readonly T[]; keys: string[]; contentVersions: number[]; revision: number;
|
|
52
|
+
records: Map<string, { item: T; version: number }>;
|
|
53
|
+
} | null>(null);
|
|
54
|
+
const [window, setWindow] = useState({ start: initialPosition === "end" ? Math.max(0, items.length - 32) : 0, end: initialPosition === "end" ? items.length : 32, revision: 0, nearEnd: false });
|
|
55
|
+
const requested = useRef<string | null>(null);
|
|
56
|
+
const load = useAction(() => onLoadMore?.());
|
|
57
|
+
const olderRequested = useRef<string | null>(null);
|
|
58
|
+
const older = useAction(() => onLoadOlder?.());
|
|
59
|
+
let start = Math.min(window.start, items.length);
|
|
60
|
+
let end = Math.min(window.end, items.length);
|
|
61
|
+
if (initialWindow.current && items.length) {
|
|
62
|
+
start = Math.max(0, items.length - 32);
|
|
63
|
+
end = items.length;
|
|
64
|
+
}
|
|
65
|
+
let data = previous.current;
|
|
66
|
+
const old = data;
|
|
67
|
+
const keys = old && old.items === items && old.keyExtractor === keyExtractor ? old.keys : items.map(keyExtractor);
|
|
68
|
+
if (keys !== old?.keys && (keys.some(key => typeof key !== "string") || new Set(keys).size !== keys.length)) {
|
|
69
|
+
throw new Error("List keys must be unique strings");
|
|
70
|
+
}
|
|
71
|
+
const keysChanged = old && keys !== old.keys && (keys.length !== old.keys.length || keys.some((key, i) => key !== old.keys[i]));
|
|
72
|
+
if (!data || data.items !== items || data.measurementKey !== measurementKey || keysChanged) {
|
|
73
|
+
const revision = (old?.revision ?? 0) + 1;
|
|
74
|
+
const records = new Map(keys.map((key, index) => {
|
|
75
|
+
const oldRecord = old?.records.get(key);
|
|
76
|
+
return [key, { item: items[index], version: oldRecord && old?.measurementKey === measurementKey && Object.is(oldRecord.item, items[index]) ? oldRecord.version : revision }];
|
|
77
|
+
}));
|
|
78
|
+
const contentVersions = Array.from(records.values(), record => record.version);
|
|
79
|
+
if (new TextEncoder().encode(JSON.stringify({ keys, contentVersions })).length > 128 * 1024) {
|
|
80
|
+
throw new Error("List metadata exceeds 128 KiB; use shorter keys or a bounded data window");
|
|
81
|
+
}
|
|
82
|
+
if (initialWindow.current && items.length) {
|
|
83
|
+
if (start !== window.start || end !== window.end) setWindow({ start, end, revision, nearEnd: false });
|
|
84
|
+
} else if (old && window.revision !== revision && old.keys[window.start] !== undefined) {
|
|
85
|
+
const mapped = keys.indexOf(old.keys[window.start]);
|
|
86
|
+
start = mapped < 0 ? Math.min(window.start, Math.max(0, items.length - 1)) : mapped;
|
|
87
|
+
end = Math.min(start + window.end - window.start, items.length);
|
|
88
|
+
if (start !== window.start || end !== window.end) setWindow({ start, end, revision, nearEnd: false });
|
|
89
|
+
}
|
|
90
|
+
data = { items, keys, keyExtractor, contentVersions, revision, records, measurementKey };
|
|
91
|
+
}
|
|
92
|
+
if (data.keyExtractor !== keyExtractor) data = { ...data, keyExtractor };
|
|
93
|
+
useLayoutEffect(() => {
|
|
94
|
+
previous.current = data;
|
|
95
|
+
if (items.length) initialWindow.current = false;
|
|
96
|
+
});
|
|
97
|
+
const { revision, contentVersions } = data;
|
|
98
|
+
const boundary = JSON.stringify([keys.length, keys.at(-1)]);
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!onLoadMore || !hasMore || load.status === "pending" || load.status === "error"
|
|
101
|
+
|| window.revision !== revision || !window.nearEnd || requested.current === boundary) return;
|
|
102
|
+
requested.current = boundary;
|
|
103
|
+
load.run();
|
|
104
|
+
}, [onLoadMore, hasMore, load.status, load.run, window, revision, boundary]);
|
|
105
|
+
const firstBoundary = JSON.stringify([keys.length, keys[0]]);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!onLoadOlder || !hasOlder || older.status === "pending" || older.status === "error"
|
|
108
|
+
|| window.revision !== revision || window.start > OLDER_PAGE_AHEAD_ITEMS || olderRequested.current === firstBoundary) return;
|
|
109
|
+
olderRequested.current = firstBoundary;
|
|
110
|
+
older.run();
|
|
111
|
+
}, [onLoadOlder, hasOlder, older.status, older.run, window, revision, firstBoundary]);
|
|
112
|
+
const children: ReactNode[] = [];
|
|
113
|
+
for (let index = start; index < end; index++) {
|
|
114
|
+
children.push(createElement("Stack", { key: keys[index], axis: "vertical", align: "stretch" }, renderItem(items[index], index)));
|
|
115
|
+
}
|
|
116
|
+
const props = {
|
|
117
|
+
keys: data.keys,
|
|
118
|
+
contentVersions,
|
|
119
|
+
revision,
|
|
120
|
+
gap,
|
|
121
|
+
followEnd,
|
|
122
|
+
start,
|
|
123
|
+
onWindow(start: number, end: number, eventRevision: number, nearEnd: boolean) {
|
|
124
|
+
if (eventRevision !== revision) return;
|
|
125
|
+
setWindow(previous => previous.start === start && previous.end === end && previous.revision === eventRevision && previous.nearEnd === nearEnd ? previous : { start, end, revision: eventRevision, nearEnd });
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
const list = createElement("List", props, children);
|
|
129
|
+
return createElement(Fragment, null,
|
|
130
|
+
older.status === "error" && createElement(ListLoadError, { message: older.error.message, onRetry: older.run }),
|
|
131
|
+
list,
|
|
132
|
+
load.status === "error" && createElement(ListLoadError, { message: load.error.message, onRetry: load.run }),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { createElement, Fragment, useLayoutEffect, useMemo, useRef, type ComponentProps, type ReactNode } from "./react";
|
|
2
|
+
import { ListLoadError, ReactList, type ListProps } from "./list";
|
|
3
|
+
import { useAction } from "./action";
|
|
4
|
+
import type { Row } from "./image";
|
|
5
|
+
import { pressHandler } from "./press";
|
|
6
|
+
import { rememberListPatch, type NativeListItem } from "./list-patch";
|
|
7
|
+
|
|
8
|
+
type Captured = { value: unknown; prototype?: object | null; fields?: [PropertyKey, boolean, Captured][] };
|
|
9
|
+
type Scope = { identity: string; values: readonly Captured[] };
|
|
10
|
+
type Capture = { identity: string; values: () => readonly unknown[]; usesIndex?: boolean };
|
|
11
|
+
type Plan<T> = { project: (item: T, index: number) => unknown[]; template: string; scope?: Capture };
|
|
12
|
+
const needsReact = /* @__PURE__ */ Symbol("list needs React");
|
|
13
|
+
const plans = /* @__PURE__ */ new WeakMap<Function, Plan<unknown>>();
|
|
14
|
+
const keys = /* @__PURE__ */ new WeakMap<Function, string>();
|
|
15
|
+
const plainRecords = /* @__PURE__ */ new WeakSet<object>();
|
|
16
|
+
declare const __inkIsProxy: (value: unknown) => boolean;
|
|
17
|
+
const isProxy = typeof __inkIsProxy === "function" ? __inkIsProxy : undefined;
|
|
18
|
+
|
|
19
|
+
function bareFunctionPrototype(value: unknown, owner: Function): value is object {
|
|
20
|
+
if (value === null || typeof value !== "object" || isProxy?.(value)) return false;
|
|
21
|
+
if (Object.getPrototypeOf(value) !== Object.prototype || Reflect.ownKeys(value).length !== 1) return false;
|
|
22
|
+
const constructor = Object.getOwnPropertyDescriptor(value, "constructor");
|
|
23
|
+
return !!constructor && "value" in constructor && constructor.value === owner;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function plainData(value: unknown, cache = plainRecords, visiting = new Set<object>()): boolean {
|
|
27
|
+
if (value === null || typeof value !== "object" && typeof value !== "function") return true;
|
|
28
|
+
if (cache.has(value)) return true;
|
|
29
|
+
if (isProxy?.(value)) return false;
|
|
30
|
+
if (visiting.has(value) || visiting.size >= 64) return false;
|
|
31
|
+
const prototype = Object.getPrototypeOf(value);
|
|
32
|
+
if (prototype !== null && prototype !== Object.prototype && prototype !== Array.prototype
|
|
33
|
+
&& !(typeof value === "function" && prototype === Function.prototype)) return false;
|
|
34
|
+
visiting.add(value);
|
|
35
|
+
const properties: Record<PropertyKey, PropertyDescriptor> = Object.getOwnPropertyDescriptors(value);
|
|
36
|
+
const plain = Reflect.ownKeys(properties).every(key => {
|
|
37
|
+
const property = properties[key];
|
|
38
|
+
if (!("value" in property)) return false;
|
|
39
|
+
if (["toString", "valueOf", Symbol.toPrimitive].includes(key) && typeof property.value === "function") return false;
|
|
40
|
+
if (key === "prototype" && typeof value === "function" && bareFunctionPrototype(property.value, value)) return true;
|
|
41
|
+
return plainData(property.value, cache, visiting);
|
|
42
|
+
});
|
|
43
|
+
visiting.delete(value);
|
|
44
|
+
if (plain && isProxy) cache.add(value);
|
|
45
|
+
return plain;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @internal A compiler-proven field extractor has no captured state. */
|
|
49
|
+
export function nativeListKey<T>(extract: (item: T, index: number) => string, identity: string) {
|
|
50
|
+
keys.set(extract, identity);
|
|
51
|
+
return extract;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @internal Created by the Ink compiler; application List props stay unchanged. */
|
|
55
|
+
export function nativeListRow<T>(render: (item: T, index: number) => ReactNode, project: Plan<T>["project"], template: string, scope?: Capture) {
|
|
56
|
+
plans.set(render, { project: project as Plan<unknown>["project"], template, scope });
|
|
57
|
+
return render;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @internal Match React's primitive text children without invoking components. */
|
|
61
|
+
export function listText(parts: unknown[]): string {
|
|
62
|
+
return parts.map(value => {
|
|
63
|
+
if (value == null || typeof value === "boolean") return "";
|
|
64
|
+
if (Array.isArray(value)) return listText(value);
|
|
65
|
+
if (!["string", "number", "bigint"].includes(typeof value)) throw needsReact;
|
|
66
|
+
return String(value);
|
|
67
|
+
}).join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @internal The same presentation fields as Ink's stateless Row component. */
|
|
71
|
+
export function listRowFields(props: ComponentProps<typeof Row>) {
|
|
72
|
+
return { image: props.image, title: props.title, titleMaxLines: props.titleMaxLines,
|
|
73
|
+
titleIcon: props.titleIcon, subtitle: props.subtitle, subtitleIcon: props.subtitleIcon,
|
|
74
|
+
onLongPress: props.onLongPress,
|
|
75
|
+
onPress: pressHandler(props) ?? (props.onLongPress ? () => {} : undefined) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function nativeValue(value: unknown, previous?: unknown): unknown {
|
|
79
|
+
if (typeof value === "function") return true;
|
|
80
|
+
if (value == null) return null;
|
|
81
|
+
if (Array.isArray(value)) {
|
|
82
|
+
const before = Array.isArray(previous) ? previous : [];
|
|
83
|
+
const next = value.map((item, index) => nativeValue(item, before[index]));
|
|
84
|
+
return before.length === next.length && next.every((item, index) => Object.is(item, before[index])) ? before : next;
|
|
85
|
+
}
|
|
86
|
+
if (typeof value === "object") {
|
|
87
|
+
const before = previous !== null && typeof previous === "object" && !Array.isArray(previous)
|
|
88
|
+
? previous as Record<string, unknown> : {};
|
|
89
|
+
const next: Record<string, unknown> = {};
|
|
90
|
+
let unchanged = true;
|
|
91
|
+
for (const key of Object.keys(value)) {
|
|
92
|
+
const item = nativeValue(Reflect.get(value, key), before[key]);
|
|
93
|
+
next[key] = item;
|
|
94
|
+
if (!Object.hasOwn(before, key) || !Object.is(item, before[key])) unchanged = false;
|
|
95
|
+
}
|
|
96
|
+
return unchanged && Object.keys(next).length === Object.keys(before).length ? before : next;
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type NativeItem = NativeListItem;
|
|
102
|
+
type RowRecord<T> = { item: T; index: number; native: NativeItem };
|
|
103
|
+
type Records<T> = { data: NativeItem[]; rows: Map<string, RowRecord<T>>; template: string;
|
|
104
|
+
items: readonly T[]; key: string | Function; measurementKey?: string | number; scope?: Scope };
|
|
105
|
+
|
|
106
|
+
function captureValue(value: unknown, cache: WeakMap<object, Captured>): Captured {
|
|
107
|
+
if (value === null || typeof value !== "object" && typeof value !== "function") return { value };
|
|
108
|
+
const previous = cache.get(value);
|
|
109
|
+
if (previous) return previous;
|
|
110
|
+
const properties: Record<PropertyKey, PropertyDescriptor> = Object.getOwnPropertyDescriptors(value);
|
|
111
|
+
const captured: Captured = { value, prototype: Object.getPrototypeOf(value), fields: Reflect.ownKeys(properties).map(key => {
|
|
112
|
+
const property = properties[key];
|
|
113
|
+
const field = key === "prototype" && typeof value === "function" && bareFunctionPrototype(property.value, value)
|
|
114
|
+
? { value: property.value } : captureValue(property.value, cache);
|
|
115
|
+
return [key, !!property.enumerable, field];
|
|
116
|
+
}) };
|
|
117
|
+
cache.set(value, captured);
|
|
118
|
+
return captured;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sameCapture(before: Captured, after: Captured, seen: WeakMap<Captured, Captured>): boolean {
|
|
122
|
+
// Other JS hosts cannot establish that an object has no Proxy traps.
|
|
123
|
+
if (before.value !== null && (typeof before.value === "object" || typeof before.value === "function") && !isProxy) return false;
|
|
124
|
+
if (!Object.is(before.value, after.value) || before.prototype !== after.prototype) return false;
|
|
125
|
+
const previous = before.fields, current = after.fields;
|
|
126
|
+
if (!previous || !current) return previous === current;
|
|
127
|
+
if (seen.get(before) === after) return true;
|
|
128
|
+
seen.set(before, after);
|
|
129
|
+
return previous.length === current.length && current.every(([key, enumerable, value], index) =>
|
|
130
|
+
key === previous[index][0] && enumerable === previous[index][1] && sameCapture(previous[index][2], value, seen));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sameScopeValues(before: readonly Captured[], after: readonly Captured[]): boolean {
|
|
134
|
+
const seen = new WeakMap<Captured, Captured>();
|
|
135
|
+
return before.length === after.length && after.every((value, index) => sameCapture(before[index], value, seen));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function nativeListPlan<T>(render: ListProps<T>["renderItem"]): Plan<T> | undefined {
|
|
139
|
+
return plans.get(render) as Plan<T> | undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function CompiledList<T>({ plan, ...props }: ListProps<T> & { plan: Plan<T> }) {
|
|
143
|
+
const { items, keyExtractor, gap = 47, followEnd, initialPosition, measurementKey, onLoadMore, onLoadOlder, hasMore = false, hasOlder = false } = props;
|
|
144
|
+
if (!Number.isFinite(gap) || gap < 0) throw new Error("List gap must be finite and non-negative");
|
|
145
|
+
const load = useAction(() => onLoadMore?.());
|
|
146
|
+
const older = useAction(() => onLoadOlder?.());
|
|
147
|
+
const committed = useRef<Records<T> | null>(null);
|
|
148
|
+
const records = useMemo(() => {
|
|
149
|
+
const previous = committed.current;
|
|
150
|
+
const keyIdentity = keys.get(keyExtractor) ?? keyExtractor;
|
|
151
|
+
const captured = plan.scope && (items.length ? plan.scope.values() : []);
|
|
152
|
+
if (captured && !captured.every(value => plainData(value, new WeakSet()))) return null;
|
|
153
|
+
const snapshots = new WeakMap<object, Captured>();
|
|
154
|
+
const scope = plan.scope && { identity: plan.scope.identity, values: captured?.map(value => captureValue(value, snapshots)) ?? [] };
|
|
155
|
+
const sameScope = scope && previous?.scope?.identity === scope.identity && sameScopeValues(previous.scope.values, scope.values);
|
|
156
|
+
if (previous && previous.items === items && previous.key === keyIdentity
|
|
157
|
+
&& previous.template === plan.template && previous.measurementKey === measurementKey
|
|
158
|
+
&& sameScope) return previous;
|
|
159
|
+
const rows = new Map<string, RowRecord<T>>();
|
|
160
|
+
let unchanged = items.length === previous?.data.length;
|
|
161
|
+
let reordered = !unchanged;
|
|
162
|
+
const changes: NativeItem[] = [];
|
|
163
|
+
let data;
|
|
164
|
+
try {
|
|
165
|
+
if (!items.every(item => plainData(item))) throw needsReact;
|
|
166
|
+
data = items.map((item, index) => {
|
|
167
|
+
const key = typeof keyIdentity === "string" && previous?.key === keyIdentity && previous.items[index] === item
|
|
168
|
+
? previous.data[index].key : keyExtractor(item, index);
|
|
169
|
+
if (typeof key !== "string" || rows.has(key)) throw new Error("List keys must be unique strings");
|
|
170
|
+
const beforeRow = previous?.template === plan.template ? previous.rows.get(key) : undefined;
|
|
171
|
+
const before = beforeRow?.native;
|
|
172
|
+
const projected = beforeRow && sameScope && beforeRow.item === item
|
|
173
|
+
&& (beforeRow.index === index || plan.scope?.usesIndex === false)
|
|
174
|
+
? beforeRow.native.values : nativeValue(plan.project(item, index), before?.values);
|
|
175
|
+
const measurement = measurementKey ?? null;
|
|
176
|
+
const record = before && before.values === projected && before.measurementKey === measurement
|
|
177
|
+
? before : { key, values: projected, measurementKey: measurement };
|
|
178
|
+
rows.set(key, { item, index, native: record });
|
|
179
|
+
if (record !== previous?.data[index]) {
|
|
180
|
+
unchanged = false;
|
|
181
|
+
if (record !== before) changes.push(record);
|
|
182
|
+
if (key !== previous?.data[index]?.key) reordered = true;
|
|
183
|
+
}
|
|
184
|
+
return record;
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (error !== needsReact) throw error;
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
if (unchanged && previous) data = previous.data;
|
|
191
|
+
else if (previous) rememberListPatch(data, { previous: previous.data, changes,
|
|
192
|
+
...(reordered ? { keys: data.map(item => item.key) } : {}) });
|
|
193
|
+
return { data, rows, template: plan.template, items, key: keyIdentity, measurementKey, scope };
|
|
194
|
+
}, [items, keyExtractor, plan, measurementKey]);
|
|
195
|
+
useLayoutEffect(() => { committed.current = records; }, [records]);
|
|
196
|
+
const targets = useMemo(() => {
|
|
197
|
+
type Template = { id: number; props: Record<string, unknown>; children: Template[] };
|
|
198
|
+
const targets = new Map<number, Record<string, (string | number)[]>>();
|
|
199
|
+
const visit = (node: Template) => {
|
|
200
|
+
const events: Record<string, (string | number)[]> = {};
|
|
201
|
+
for (const [name, value] of Object.entries(node.props)) {
|
|
202
|
+
if (name.startsWith("on") && typeof value === "object" && value !== null && "path" in value) {
|
|
203
|
+
const path = value.path;
|
|
204
|
+
if (Array.isArray(path) && path.every(part => typeof part === "string" || typeof part === "number")) events[name] = path.slice(1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
targets.set(node.id, events);
|
|
208
|
+
node.children.forEach(visit);
|
|
209
|
+
};
|
|
210
|
+
visit(JSON.parse(plan.template) as Template);
|
|
211
|
+
return targets;
|
|
212
|
+
}, [plan.template]);
|
|
213
|
+
if (!records) return createElement(ReactList<T>, props);
|
|
214
|
+
const nativeProps = {
|
|
215
|
+
items: records.data, keyField: "key", template: plan.template, gap, followEnd, initialEnd: initialPosition === "end",
|
|
216
|
+
hasMore: hasMore && load.status !== "pending" && load.status !== "error",
|
|
217
|
+
hasOlder: hasOlder && older.status !== "pending" && older.status !== "error",
|
|
218
|
+
onEndReached: onLoadMore ? load.run : undefined,
|
|
219
|
+
onStartReached: onLoadOlder ? older.run : undefined,
|
|
220
|
+
onRowEvent(key: string, target: number, name: string, ...args: unknown[]) {
|
|
221
|
+
const path = targets.get(target)?.[name];
|
|
222
|
+
const row = records.rows.get(key);
|
|
223
|
+
let callback: unknown = row && path ? plan.project(row.item, row.index) : undefined;
|
|
224
|
+
for (const part of path ?? []) {
|
|
225
|
+
callback = callback != null && typeof callback === "object" ? Reflect.get(callback, part) : undefined;
|
|
226
|
+
}
|
|
227
|
+
if (path && typeof callback === "function") callback(...args);
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
return createElement(Fragment, null,
|
|
231
|
+
older.status === "error" && createElement(ListLoadError, { message: older.error.message, onRetry: older.run }),
|
|
232
|
+
createElement("NativeList", nativeProps),
|
|
233
|
+
load.status === "error" && createElement(ListLoadError, { message: load.error.message, onRetry: load.run }),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** @internal Fixed native descriptions for Ink's own list components. */
|
|
238
|
+
export function nativeListTemplate(build: (
|
|
239
|
+
node: (type: string, props: Record<string, unknown>, children?: NativeRowNode[]) => NativeRowNode,
|
|
240
|
+
field: (...path: (string | number)[]) => unknown,
|
|
241
|
+
) => NativeRowNode): string {
|
|
242
|
+
let next = 1;
|
|
243
|
+
return JSON.stringify({ id: 0, type: "Stack", props: { gap: 0, align: "stretch" }, children: [build(
|
|
244
|
+
(type, props, children = []) => ({ id: next++, type, props, children }),
|
|
245
|
+
(...path) => ({ $value: 0, path: ["values", 0, ...path] }),
|
|
246
|
+
)] });
|
|
247
|
+
}
|
|
248
|
+
type NativeRowNode = { id: number; type: string; props: Record<string, unknown>; children: NativeRowNode[] };
|