ink-framework 0.1.0-alpha.10 → 0.1.0-alpha.13
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/DESIGN.md +168 -0
- package/package.json +9 -4
- package/src/canvas.ts +59 -0
- package/src/compiler-runtime.ts +6 -0
- package/src/controller-react.ts +33 -0
- package/src/data-views.ts +249 -0
- package/src/file-router.tsx +1 -1
- package/src/fixed-list-row.ts +65 -0
- package/src/image.ts +30 -2
- package/src/index.ts +31 -4
- package/src/jsx-runtime.ts +21 -0
- package/src/list.ts +71 -34
- package/src/native-list.ts +273 -74
- package/src/native.ts +8 -0
- package/src/prefresh.d.ts +3 -0
- package/src/react-compat.ts +24 -0
- package/src/react.ts +26 -6
- package/src/refresh.ts +29 -0
- package/src/renderer.ts +183 -137
- package/src/reorder.ts +12 -15
- package/src/snapshot.ts +1 -1
- package/src/visibility.ts +264 -0
package/src/image.ts
CHANGED
|
@@ -12,17 +12,19 @@ export function Avatar({ src = "", unread = false, onPress, onLongPress }: {
|
|
|
12
12
|
return createElement("Avatar", { src, unread, onPress, onLongPress });
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function RowComponent<const D extends Destination>({ image, title, titleMaxLines, titleIcon, subtitle, subtitleIcon, href, url, onPress, onLongPress }: PressProps<D> & {
|
|
15
|
+
function RowComponent<const D extends Destination>({ image, title, titleMaxLines, titleIcon, subtitle, subtitleIcon, selected, href, url, onPress, onLongPress }: PressProps<D> & {
|
|
16
16
|
image?: string;
|
|
17
17
|
title: string;
|
|
18
18
|
titleMaxLines?: number;
|
|
19
19
|
titleIcon?: IconAsset;
|
|
20
20
|
subtitle?: string;
|
|
21
21
|
subtitleIcon?: IconAsset;
|
|
22
|
+
/** Underlines the row's text block, such as for the current choice. */
|
|
23
|
+
selected?: boolean;
|
|
22
24
|
onLongPress?: () => void;
|
|
23
25
|
}) {
|
|
24
26
|
return createElement("RowContent", {
|
|
25
|
-
image, title, titleMaxLines, titleIcon, subtitle, subtitleIcon,
|
|
27
|
+
image, title, titleMaxLines, titleIcon, subtitle, subtitleIcon, selected,
|
|
26
28
|
onPress: pressHandler({ href, url, onPress }) ?? (onLongPress ? () => {} : undefined),
|
|
27
29
|
onLongPress,
|
|
28
30
|
});
|
|
@@ -30,16 +32,42 @@ function RowComponent<const D extends Destination>({ image, title, titleMaxLines
|
|
|
30
32
|
|
|
31
33
|
export const Row = /* @__PURE__ */ memo(RowComponent) as typeof RowComponent;
|
|
32
34
|
|
|
35
|
+
export type ImageCrop = { zoom: number; x: number; y: number };
|
|
36
|
+
export type ImageOverlay = { src: string; x: number; y: number; width: number; height: number };
|
|
37
|
+
|
|
33
38
|
export function Image(props: {
|
|
34
39
|
src: string;
|
|
35
40
|
width: number;
|
|
36
41
|
height: number;
|
|
37
42
|
fit?: "contain" | "cover";
|
|
43
|
+
/** Clockwise quarter turns. Reuses one decoded texture across rotation and fit changes. */
|
|
44
|
+
rotation?: 0 | 90 | 180 | 270;
|
|
45
|
+
background?: string;
|
|
46
|
+
/** Normalised centre in the rotated source, with zoom relative to cover (1–4). */
|
|
47
|
+
crop?: ImageCrop;
|
|
48
|
+
/** Native gesture updates, delivered before subsequent press callbacks. */
|
|
49
|
+
onCropChange?: (crop: ImageCrop) => void;
|
|
50
|
+
/** Dashed horizontal guides at normalised frame positions (0–1), independent of crop. */
|
|
51
|
+
horizontalGuides?: readonly number[];
|
|
52
|
+
/** Transparent image in normalised frame coordinates, independent of crop and rotation. */
|
|
53
|
+
overlay?: ImageOverlay;
|
|
38
54
|
bleed?: boolean;
|
|
39
55
|
/** Fill the available content width, using width and height as the aspect ratio. */
|
|
40
56
|
fillWidth?: boolean;
|
|
41
57
|
zoomable?: boolean;
|
|
42
58
|
loop?: boolean;
|
|
59
|
+
/** Up to two sources decoded ahead at this image's size, such as neighbouring photos. */
|
|
60
|
+
preload?: readonly string[];
|
|
61
|
+
onPress?: () => void;
|
|
62
|
+
/** A play triangle centred over the image, such as on a video poster. */
|
|
63
|
+
playIcon?: boolean;
|
|
64
|
+
/** A video from `useVideo` drawn over this image, such as over its poster frame. */
|
|
65
|
+
videoController?: number | null;
|
|
66
|
+
/** A horizontal swipe: `"next"` when swiped left, `"previous"` when swiped right. */
|
|
67
|
+
onSwipe?: (direction: "next" | "previous") => void;
|
|
68
|
+
/** Whether a swipe can continue in each direction. Both default to true. */
|
|
69
|
+
hasNext?: boolean;
|
|
70
|
+
hasPrevious?: boolean;
|
|
43
71
|
}) {
|
|
44
72
|
return createElement("Image", props);
|
|
45
73
|
}
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,19 @@ export { useAction, type Action } from "./action";
|
|
|
13
13
|
export { NativeError } from "./native";
|
|
14
14
|
export { Tabs, Slot, navigate, replace, back, useRouteParams, type Destination } from "./navigation";
|
|
15
15
|
|
|
16
|
-
export
|
|
16
|
+
export type ActionBarItem = { icon?: IconAsset; label?: string; onPress: () => void; disabled?: boolean } | null;
|
|
17
|
+
|
|
18
|
+
/** @internal Host props for a bottom action bar of up to three slots. */
|
|
19
|
+
export function actionBarProps(items: readonly ActionBarItem[] | undefined) {
|
|
20
|
+
if (!items) return {};
|
|
21
|
+
const props: Record<string, unknown> = {
|
|
22
|
+
actionBar: items.map(item => item && { icon: item.icon, label: item.label, disabled: item.disabled ?? false }),
|
|
23
|
+
};
|
|
24
|
+
items.forEach((item, index) => { if (item && !item.disabled) props[`onAction${index}`] = item.onPress; });
|
|
25
|
+
return props;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Screen({ header, children, overlay, leftAction, rightAction, actionBar, ...props }: {
|
|
17
29
|
children?: ReactNode;
|
|
18
30
|
header?: ReactNode;
|
|
19
31
|
title?: string;
|
|
@@ -21,15 +33,29 @@ export function Screen({ header, children, leftAction, rightAction, ...props }:
|
|
|
21
33
|
centered?: boolean;
|
|
22
34
|
wide?: boolean;
|
|
23
35
|
bottomInset?: boolean;
|
|
24
|
-
waitForImages?: boolean;
|
|
25
36
|
leftAction?: { icon: IconAsset; onPress: () => void };
|
|
26
37
|
rightAction?: { icon: IconAsset; onPress: () => void };
|
|
38
|
+
/** Up to three actions in equal slots along the bottom; `null` leaves a slot empty. */
|
|
39
|
+
actionBar?: readonly ActionBarItem[];
|
|
40
|
+
/** `"auto"` hides the scrollbar until the page scrolls. */
|
|
41
|
+
scrollBar?: "auto";
|
|
42
|
+
/** Hides the header and action bar while keeping their space, so content stays in place. */
|
|
43
|
+
immersive?: boolean;
|
|
44
|
+
/** Lets content fill the screen, with the header and action bar drawn over it. */
|
|
45
|
+
overlayBars?: boolean;
|
|
46
|
+
/** Content above the bottom action bar, such as video progress. Hidden in immersive mode. */
|
|
47
|
+
overlay?: ReactNode;
|
|
48
|
+
/** Makes the title pressable, such as to choose what the page shows. */
|
|
49
|
+
onTitlePress?: () => void;
|
|
50
|
+
/** An icon after the title, such as a chevron for `onTitlePress`. */
|
|
51
|
+
titleIcon?: IconAsset;
|
|
27
52
|
}) {
|
|
28
53
|
const [scrollToEnd, setScrollToEnd] = useState(0);
|
|
29
54
|
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 },
|
|
55
|
+
return createElement(ScrollToEndContext.Provider, { value: requestEnd }, createElement("Screen", { ...props, ...actionBarProps(actionBar), scrollToEnd, pinnedHeader: header != null, leftIcon: leftAction?.icon, onLeftPress: leftAction?.onPress, rightIcon: rightAction?.icon, onRightPress: rightAction?.onPress },
|
|
31
56
|
header != null && createElement(Stack, { gap: 47 }, header),
|
|
32
57
|
children,
|
|
58
|
+
overlay != null && createElement("ScreenOverlay", null, overlay),
|
|
33
59
|
));
|
|
34
60
|
}
|
|
35
61
|
|
|
@@ -91,10 +117,11 @@ export function Button<const D extends Destination>(props: PressProps<D> & {
|
|
|
91
117
|
}
|
|
92
118
|
|
|
93
119
|
export { Row, Image, Avatar } from "./image";
|
|
94
|
-
export { Canvas, Rectangle, CanvasText, CanvasIcon } from "./canvas";
|
|
120
|
+
export { Canvas, Rectangle, CanvasPath, CanvasRange, CanvasText, CanvasIcon } from "./canvas";
|
|
95
121
|
|
|
96
122
|
export function Field<const D extends Destination>(props: PressProps<D> & {
|
|
97
123
|
label: string;
|
|
124
|
+
disabled?: boolean;
|
|
98
125
|
children?: ReactNode;
|
|
99
126
|
}) {
|
|
100
127
|
const { href, url, onPress, ...rest } = props;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Fragment, options, type ComponentType, type VNode } from "preact";
|
|
2
|
+
export { Fragment };
|
|
3
|
+
let nextId = 0;
|
|
4
|
+
|
|
5
|
+
/** Preact's vnode contract without unused browser/debug fields on every node. */
|
|
6
|
+
export function jsx(type: string | ComponentType, incoming: Record<string, unknown> | null, key?: string) {
|
|
7
|
+
let props = incoming ?? {};
|
|
8
|
+
let ref: unknown;
|
|
9
|
+
if ("ref" in props) {
|
|
10
|
+
({ ref, ...props } = props);
|
|
11
|
+
}
|
|
12
|
+
if (typeof type === "function" && type.defaultProps) {
|
|
13
|
+
for (const name in type.defaultProps) {
|
|
14
|
+
if (props[name] === undefined) props[name] = (type.defaultProps as Record<string, unknown>)[name];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const vnode = { type, props, key, ref, constructor: undefined, __v: --nextId } as unknown as VNode;
|
|
18
|
+
if (typeof type === "function") options.vnode?.(vnode);
|
|
19
|
+
return vnode;
|
|
20
|
+
}
|
|
21
|
+
export { jsx as jsxs, jsx as jsxDEV };
|
package/src/list.ts
CHANGED
|
@@ -5,9 +5,7 @@ import { Button, Stack, Text } from "./index";
|
|
|
5
5
|
import { CompiledList, nativeListPlan } from "./native-list";
|
|
6
6
|
import { ScrollToEndContext } from "./scroll";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export function ListLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
|
8
|
+
function ListLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
|
11
9
|
return createElement(Stack, { gap: 24 },
|
|
12
10
|
createElement(Text, { size: 18 }, message),
|
|
13
11
|
createElement(Button, { onPress: onRetry }, "Try again"),
|
|
@@ -28,7 +26,65 @@ export type ListProps<T> = {
|
|
|
28
26
|
initialPosition?: "start" | "end";
|
|
29
27
|
};
|
|
30
28
|
|
|
29
|
+
type PaginationProps = Pick<ListProps<unknown>, "onLoadMore" | "hasMore" | "onLoadOlder" | "hasOlder">;
|
|
30
|
+
export type ListPagination = ReturnType<typeof useListPagination>;
|
|
31
|
+
|
|
32
|
+
function useListPage(load: (() => Promise<void>) | undefined, available: boolean) {
|
|
33
|
+
const accepted = useRef<string | undefined>(undefined);
|
|
34
|
+
const action = useAction((edge?: string) => {
|
|
35
|
+
// useAction may decline a pending request; record acceptance only when it starts.
|
|
36
|
+
if (edge !== undefined) accepted.current = edge;
|
|
37
|
+
return load?.();
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
available: available && action.status !== "error",
|
|
41
|
+
enabled: !!load,
|
|
42
|
+
pending: action.status === "pending",
|
|
43
|
+
error: action.status === "error" ? action.error : undefined,
|
|
44
|
+
retry: () => action.run(),
|
|
45
|
+
request(edge: string) {
|
|
46
|
+
if (available && load && action.status !== "pending" && action.status !== "error" && accepted.current !== edge) action.run(edge);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function useListPagination({ onLoadMore, hasMore = false, onLoadOlder, hasOlder = false }: PaginationProps) {
|
|
52
|
+
const more = useListPage(onLoadMore, hasMore);
|
|
53
|
+
const older = useListPage(onLoadOlder, hasOlder);
|
|
54
|
+
return {
|
|
55
|
+
more,
|
|
56
|
+
older,
|
|
57
|
+
wrap(list: ReactNode) {
|
|
58
|
+
return createElement(Fragment, null,
|
|
59
|
+
older.error && createElement(ListLoadError, { message: older.error.message, onRetry: older.retry }),
|
|
60
|
+
list,
|
|
61
|
+
more.error && createElement(ListLoadError, { message: more.error.message, onRetry: more.retry }),
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function useListPaginationEvents(pagination: ListPagination, paused: boolean, count: number, first?: string, last?: string) {
|
|
68
|
+
const [startRequest, setStartRequest] = useState<string>();
|
|
69
|
+
const [endRequest, setEndRequest] = useState<string>();
|
|
70
|
+
const start = JSON.stringify([count, first ?? null]);
|
|
71
|
+
const end = JSON.stringify([count, last ?? null]);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!paused && startRequest === start) pagination.older.request(startRequest);
|
|
74
|
+
if (!paused && endRequest === end) pagination.more.request(endRequest);
|
|
75
|
+
}, [pagination, paused, startRequest, endRequest, start, end]);
|
|
76
|
+
return {
|
|
77
|
+
hasMore: !paused && pagination.more.available && !pagination.more.pending,
|
|
78
|
+
hasOlder: !paused && pagination.older.available && !pagination.older.pending,
|
|
79
|
+
moreAvailable: pagination.more.available && pagination.more.enabled,
|
|
80
|
+
// Retain notifications even when the current commit cannot start loading yet.
|
|
81
|
+
onEndReached: setEndRequest,
|
|
82
|
+
onStartReached: setStartRequest,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
31
86
|
export function List<T>(props: ListProps<T>) {
|
|
87
|
+
const pagination = useListPagination(props);
|
|
32
88
|
const requestEnd = useContext(ScrollToEndContext);
|
|
33
89
|
const initialised = useRef(false);
|
|
34
90
|
useLayoutEffect(() => {
|
|
@@ -37,10 +93,12 @@ export function List<T>(props: ListProps<T>) {
|
|
|
37
93
|
if (props.initialPosition === "end") requestEnd?.();
|
|
38
94
|
}, [props.initialPosition, props.items.length, requestEnd]);
|
|
39
95
|
const plan = nativeListPlan(props.renderItem);
|
|
40
|
-
return
|
|
96
|
+
return pagination.wrap(plan
|
|
97
|
+
? createElement(CompiledList<T>, { ...props, pagination, plan, key: plan.template })
|
|
98
|
+
: createElement(ReactList<T>, { ...props, pagination }));
|
|
41
99
|
}
|
|
42
100
|
|
|
43
|
-
export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, followEnd = false, measurementKey,
|
|
101
|
+
export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, followEnd = false, measurementKey, initialPosition = "start", pagination }: ListProps<T> & { pagination: ListPagination }) {
|
|
44
102
|
if (!Number.isFinite(gap) || gap < 0) {
|
|
45
103
|
throw new Error("List gap must be finite and non-negative");
|
|
46
104
|
}
|
|
@@ -51,11 +109,7 @@ export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, follow
|
|
|
51
109
|
items: readonly T[]; keys: string[]; contentVersions: number[]; revision: number;
|
|
52
110
|
records: Map<string, { item: T; version: number }>;
|
|
53
111
|
} | 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
|
|
55
|
-
const requested = useRef<string | null>(null);
|
|
56
|
-
const load = useAction(() => onLoadMore?.());
|
|
57
|
-
const olderRequested = useRef<string | null>(null);
|
|
58
|
-
const older = useAction(() => onLoadOlder?.());
|
|
112
|
+
const [window, setWindow] = useState({ start: initialPosition === "end" ? Math.max(0, items.length - 32) : 0, end: initialPosition === "end" ? items.length : 32, revision: 0 });
|
|
59
113
|
let start = Math.min(window.start, items.length);
|
|
60
114
|
let end = Math.min(window.end, items.length);
|
|
61
115
|
if (initialWindow.current && items.length) {
|
|
@@ -80,12 +134,12 @@ export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, follow
|
|
|
80
134
|
throw new Error("List metadata exceeds 128 KiB; use shorter keys or a bounded data window");
|
|
81
135
|
}
|
|
82
136
|
if (initialWindow.current && items.length) {
|
|
83
|
-
if (start !== window.start || end !== window.end) setWindow({ start, end, revision
|
|
137
|
+
if (start !== window.start || end !== window.end) setWindow({ start, end, revision });
|
|
84
138
|
} else if (old && window.revision !== revision && old.keys[window.start] !== undefined) {
|
|
85
139
|
const mapped = keys.indexOf(old.keys[window.start]);
|
|
86
140
|
start = mapped < 0 ? Math.min(window.start, Math.max(0, items.length - 1)) : mapped;
|
|
87
141
|
end = Math.min(start + window.end - window.start, items.length);
|
|
88
|
-
if (start !== window.start || end !== window.end) setWindow({ start, end, revision
|
|
142
|
+
if (start !== window.start || end !== window.end) setWindow({ start, end, revision });
|
|
89
143
|
}
|
|
90
144
|
data = { items, keys, keyExtractor, contentVersions, revision, records, measurementKey };
|
|
91
145
|
}
|
|
@@ -95,40 +149,23 @@ export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, follow
|
|
|
95
149
|
if (items.length) initialWindow.current = false;
|
|
96
150
|
});
|
|
97
151
|
const { revision, contentVersions } = data;
|
|
98
|
-
const
|
|
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]);
|
|
152
|
+
const paginationProps = useListPaginationEvents(pagination, window.revision !== revision, keys.length, keys[0], keys.at(-1));
|
|
112
153
|
const children: ReactNode[] = [];
|
|
113
154
|
for (let index = start; index < end; index++) {
|
|
114
155
|
children.push(createElement("Stack", { key: keys[index], axis: "vertical", align: "stretch" }, renderItem(items[index], index)));
|
|
115
156
|
}
|
|
116
157
|
const props = {
|
|
158
|
+
...paginationProps,
|
|
117
159
|
keys: data.keys,
|
|
118
160
|
contentVersions,
|
|
119
161
|
revision,
|
|
120
162
|
gap,
|
|
121
163
|
followEnd,
|
|
122
164
|
start,
|
|
123
|
-
onWindow(start: number, end: number, eventRevision: number
|
|
165
|
+
onWindow(start: number, end: number, eventRevision: number) {
|
|
124
166
|
if (eventRevision !== revision) return;
|
|
125
|
-
setWindow(previous => previous.start === start && previous.end === end && previous.revision === eventRevision
|
|
167
|
+
setWindow(previous => previous.start === start && previous.end === end && previous.revision === eventRevision ? previous : { start, end, revision: eventRevision });
|
|
126
168
|
},
|
|
127
169
|
};
|
|
128
|
-
|
|
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
|
-
);
|
|
170
|
+
return createElement("List", props, children);
|
|
134
171
|
}
|