ink-framework 0.1.0-alpha.10 → 0.1.0-alpha.12
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/package.json +2 -1
- package/src/canvas.ts +59 -0
- package/src/data-views.ts +249 -0
- package/src/image.ts +14 -0
- package/src/index.ts +2 -2
- package/src/native-list.ts +266 -61
- package/src/renderer.ts +11 -8
- package/src/snapshot.ts +1 -1
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ink-framework",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.12",
|
|
4
4
|
"types": "./src/index.ts",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
7
7
|
"./renderer": "./src/renderer.ts",
|
|
8
8
|
"./internal/list": "./src/native-list.ts",
|
|
9
|
+
"./internal/data": "./src/data-views.ts",
|
|
9
10
|
"./internal/playback": "./src/playback-binding.ts",
|
|
10
11
|
"./native": "./src/native.ts",
|
|
11
12
|
"./native/controller": "./src/controller.ts",
|
package/src/canvas.ts
CHANGED
|
@@ -15,10 +15,69 @@ export function Rectangle(props: Bounds & {
|
|
|
15
15
|
onPress?: () => void;
|
|
16
16
|
onLongPress?: () => void;
|
|
17
17
|
onDragEnter?: () => void;
|
|
18
|
+
onDragStart?: () => void;
|
|
19
|
+
onDragEnd?: () => void;
|
|
20
|
+
haptic?: boolean;
|
|
18
21
|
}) {
|
|
19
22
|
return createElement("CanvasRectangle", props);
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
/** Native-owned horizontal selection. Remount to reset its initial position. */
|
|
26
|
+
export function CanvasRange({
|
|
27
|
+
total, span, initialStart, step = 1, snapPoints = [], snapDistance = 6, tapSnapDistance = 10, hitSlop = 10,
|
|
28
|
+
minDragWidth = 0, inset = 4, innerFill = "#666666", dragOnly = false, snapOnTap = false, progress = 0,
|
|
29
|
+
progressDurationMs = 0, progressPlaying = false, progressLoop = false, progressFill = "#FFFFFF", ...props
|
|
30
|
+
}: Bounds & {
|
|
31
|
+
total: number;
|
|
32
|
+
span: number;
|
|
33
|
+
initialStart: number;
|
|
34
|
+
step?: number;
|
|
35
|
+
snapPoints?: readonly number[];
|
|
36
|
+
/** Distances and inset use Canvas logical units. Drag release distance is 1.5× entry. */
|
|
37
|
+
snapDistance?: number;
|
|
38
|
+
tapSnapDistance?: number;
|
|
39
|
+
hitSlop?: number;
|
|
40
|
+
/** Minimum selection drag target width; does not change drawing or tap-to-seek. */
|
|
41
|
+
minDragWidth?: number;
|
|
42
|
+
inset?: number;
|
|
43
|
+
fill?: string;
|
|
44
|
+
innerFill?: string;
|
|
45
|
+
/** Move only by dragging the selection; taps inside emit a relative seek. */
|
|
46
|
+
dragOnly?: boolean;
|
|
47
|
+
/** In drag-only mode, marker taps move the selection before considering a seek. */
|
|
48
|
+
snapOnTap?: boolean;
|
|
49
|
+
/** Playback fraction inside the selection, from zero to one. */
|
|
50
|
+
progress?: number;
|
|
51
|
+
/** Enables native playback interpolation; zero keeps progress static. */
|
|
52
|
+
progressDurationMs?: number;
|
|
53
|
+
/** Actual playback advancement, false while paused or buffering. */
|
|
54
|
+
progressPlaying?: boolean;
|
|
55
|
+
progressLoop?: boolean;
|
|
56
|
+
progressFill?: string;
|
|
57
|
+
onRangeSeek?: (position: number) => void;
|
|
58
|
+
onRangeStart?: () => void;
|
|
59
|
+
onRangeCommit?: (start: number) => void;
|
|
60
|
+
/** FIFO snapshot before another press or Back; never emitted for movement. */
|
|
61
|
+
onRangeSnapshot?: (start: number) => void;
|
|
62
|
+
}) {
|
|
63
|
+
return createElement("CanvasRectangle", {
|
|
64
|
+
...props,
|
|
65
|
+
range: {
|
|
66
|
+
total, span, initialStart, step, snapPoints, snapDistance, tapSnapDistance, hitSlop, minDragWidth, inset,
|
|
67
|
+
innerFill, dragOnly, snapOnTap, progress, progressDurationMs, progressPlaying, progressLoop, progressFill,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A line through points in canvas units, with round ends and joins. Paths draw above the canvas's rectangles and text. */
|
|
73
|
+
export function CanvasPath({ points, ...props }: {
|
|
74
|
+
points: readonly (readonly [x: number, y: number])[];
|
|
75
|
+
stroke?: string;
|
|
76
|
+
strokeWidth?: number;
|
|
77
|
+
}) {
|
|
78
|
+
return createElement("CanvasPath", { ...props, points: points.flat() });
|
|
79
|
+
}
|
|
80
|
+
|
|
22
81
|
export function CanvasText({ children, ...props }: Bounds & {
|
|
23
82
|
children: string | number;
|
|
24
83
|
size?: number;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
type IntrinsicCalls = {
|
|
2
|
+
filter<T>(items: readonly T[], predicate: (item: T, index: number) => boolean): T[];
|
|
3
|
+
sort<T>(items: T[], compare: (a: T, b: T) => number): T[];
|
|
4
|
+
map<T, U>(items: readonly T[], transform: (item: T, index: number) => U): U[];
|
|
5
|
+
lower(text: string): string;
|
|
6
|
+
includes(text: string, query: string): boolean;
|
|
7
|
+
localeCompare(a: string, b: string): number;
|
|
8
|
+
dateParse(value: unknown): number;
|
|
9
|
+
jsonParse(source: string, reviver: (this: object, key: string, value: unknown) => unknown): unknown;
|
|
10
|
+
weakMapGet<T>(map: WeakMap<object, T>, key: object): T | undefined;
|
|
11
|
+
weakMapSet<T>(map: WeakMap<object, T>, key: object, value: T): unknown;
|
|
12
|
+
mapGet<K, V>(map: Map<K, V>, key: K): V | undefined;
|
|
13
|
+
mapSet<K, V>(map: Map<K, V>, key: K, value: V): unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Intrinsics = {
|
|
17
|
+
WeakMap: WeakMapConstructor;
|
|
18
|
+
Map: MapConstructor;
|
|
19
|
+
calls?: IntrinsicCalls;
|
|
20
|
+
setTimeout?: (callback: () => void, delay: number) => number;
|
|
21
|
+
clearTimeout?: (timer: number) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
declare const __inkDataIntrinsics: Intrinsics | undefined;
|
|
25
|
+
declare const __inkDataFields: ((items: unknown, fields: readonly string[], previous: readonly unknown[] | undefined,
|
|
26
|
+
mode: "search" | "sort") => readonly unknown[] | undefined) | undefined;
|
|
27
|
+
declare const __inkDataSearch: ((items: unknown, fields: readonly string[], previousSnapshot: readonly unknown[] | undefined,
|
|
28
|
+
previousText: readonly string[] | undefined, separator: string | null, query?: () => unknown,
|
|
29
|
+
queryLower?: boolean) => (SearchCache & { items?: unknown[]; needle?: string }) | undefined) | undefined;
|
|
30
|
+
declare const __inkPrepareDataSearch: ((items: unknown, fields: readonly string[], separator: string | null,
|
|
31
|
+
cursor: SearchCursor | undefined, limit: number) => SearchCursor | null | undefined) | undefined;
|
|
32
|
+
|
|
33
|
+
const intrinsics = typeof __inkDataIntrinsics === "undefined" ? undefined : __inkDataIntrinsics;
|
|
34
|
+
const calls = intrinsics?.calls;
|
|
35
|
+
const snapshotFields = typeof __inkDataFields === "function" ? __inkDataFields : undefined;
|
|
36
|
+
const prepareSearch = typeof __inkDataSearch === "function" ? __inkDataSearch : undefined;
|
|
37
|
+
const warmSearch = typeof __inkPrepareDataSearch === "function" ? __inkPrepareDataSearch : undefined;
|
|
38
|
+
const schedule = intrinsics?.setTimeout;
|
|
39
|
+
const unschedule = intrinsics?.clearTimeout;
|
|
40
|
+
|
|
41
|
+
type RawPlan = { fields: unknown; separator: unknown; queryLower: unknown; date: unknown; direction: unknown; tie: unknown };
|
|
42
|
+
type SearchCache = { snapshot: readonly unknown[]; text: readonly string[] };
|
|
43
|
+
interface CachedSearch extends SearchCache { changes: number }
|
|
44
|
+
type SearchCursor = SearchCache & { offset: number; complete: boolean };
|
|
45
|
+
type SearchJob = { items: object | undefined; cursor: SearchCursor | undefined; timer: number | undefined };
|
|
46
|
+
type SearchPlan = {
|
|
47
|
+
fields: readonly string[];
|
|
48
|
+
separator: string | null;
|
|
49
|
+
queryLower: boolean;
|
|
50
|
+
cache: WeakMap<object, CachedSearch | undefined>;
|
|
51
|
+
seen: WeakMap<object, boolean>;
|
|
52
|
+
job: SearchJob | undefined;
|
|
53
|
+
retained: boolean;
|
|
54
|
+
next: SearchPlan | undefined;
|
|
55
|
+
};
|
|
56
|
+
type SortPlan = { fields: readonly string[]; direction: 1 | -1; tie: boolean };
|
|
57
|
+
type SortEntry<T> = { item: T; date: number; tie: string };
|
|
58
|
+
|
|
59
|
+
// Plans are compiler literals. Bound retained plans as well as keeping source arrays weak.
|
|
60
|
+
const maximumPlans = 64;
|
|
61
|
+
let searches: Map<string, SearchPlan> | undefined;
|
|
62
|
+
let searchCount = 0;
|
|
63
|
+
let searchHead: SearchPlan | undefined;
|
|
64
|
+
let sorts: Map<string, SortPlan> | undefined;
|
|
65
|
+
let sortCount = 0;
|
|
66
|
+
|
|
67
|
+
function parsePlan(source: string): RawPlan | undefined {
|
|
68
|
+
const i = intrinsics!;
|
|
69
|
+
const records = new i.WeakMap<object, RawPlan>();
|
|
70
|
+
try {
|
|
71
|
+
// Collect only JSON's own members, including when Object.prototype has acquired getters.
|
|
72
|
+
const parsed = calls!.jsonParse(source, function(this: object, key: string, value: unknown) {
|
|
73
|
+
if (key === "fields" || key === "separator" || key === "queryLower" || key === "date" || key === "direction" || key === "tie") {
|
|
74
|
+
let record = calls!.weakMapGet(records, this);
|
|
75
|
+
if (!record) {
|
|
76
|
+
record = { fields: undefined, separator: undefined, queryLower: undefined, date: undefined, direction: undefined, tie: undefined };
|
|
77
|
+
calls!.weakMapSet(records, this, record);
|
|
78
|
+
}
|
|
79
|
+
record[key] = value;
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
});
|
|
83
|
+
return parsed !== null && typeof parsed === "object"
|
|
84
|
+
? calls!.weakMapGet(records, parsed) : undefined;
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function searchPlan(source: string): SearchPlan | undefined {
|
|
91
|
+
const i = intrinsics!;
|
|
92
|
+
const known = searches && calls!.mapGet(searches, source);
|
|
93
|
+
if (known) return known;
|
|
94
|
+
const raw = parsePlan(source);
|
|
95
|
+
if (!raw || raw.separator !== null && typeof raw.separator !== "string"
|
|
96
|
+
|| raw.queryLower !== undefined && typeof raw.queryLower !== "boolean") return undefined;
|
|
97
|
+
// The native guard validates the fields array without invoking getters or Proxy traps.
|
|
98
|
+
const plan: SearchPlan = { fields: raw.fields as readonly string[], separator: raw.separator,
|
|
99
|
+
queryLower: raw.queryLower === true, cache: new i.WeakMap<object, CachedSearch | undefined>(),
|
|
100
|
+
seen: new i.WeakMap<object, boolean>(), job: undefined, retained: true, next: undefined };
|
|
101
|
+
if (!searches || searchCount === maximumPlans) {
|
|
102
|
+
while (searchHead) {
|
|
103
|
+
const evicted = searchHead;
|
|
104
|
+
searchHead = evicted.next;
|
|
105
|
+
evicted.next = undefined;
|
|
106
|
+
evicted.retained = false;
|
|
107
|
+
cancelSearch(evicted);
|
|
108
|
+
}
|
|
109
|
+
searches = new i.Map<string, SearchPlan>();
|
|
110
|
+
searchCount = 0;
|
|
111
|
+
}
|
|
112
|
+
calls!.mapSet(searches, source, plan);
|
|
113
|
+
plan.next = searchHead;
|
|
114
|
+
searchHead = plan;
|
|
115
|
+
searchCount++;
|
|
116
|
+
return plan;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function cancelSearch(plan: SearchPlan): void {
|
|
120
|
+
const job = plan.job;
|
|
121
|
+
if (!job) return;
|
|
122
|
+
plan.job = undefined;
|
|
123
|
+
const timer = job.timer;
|
|
124
|
+
job.items = undefined;
|
|
125
|
+
job.cursor = undefined;
|
|
126
|
+
job.timer = undefined;
|
|
127
|
+
if (timer !== undefined) {
|
|
128
|
+
try { unschedule!(timer); } catch { /* Cancellation has already invalidated the job. */ }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function disableSearch(plan: SearchPlan, items: object): void {
|
|
133
|
+
calls!.weakMapSet(plan.cache, items, undefined);
|
|
134
|
+
calls!.weakMapSet(plan.seen, items, false);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function warmLater(plan: SearchPlan, items: object): void {
|
|
138
|
+
if (!plan.retained || plan.job?.items === items) return;
|
|
139
|
+
cancelSearch(plan);
|
|
140
|
+
const job: SearchJob = { items, cursor: undefined, timer: undefined };
|
|
141
|
+
plan.job = job;
|
|
142
|
+
const step = () => {
|
|
143
|
+
if (plan.job !== job) return;
|
|
144
|
+
job.timer = undefined;
|
|
145
|
+
try {
|
|
146
|
+
const source = job.items!;
|
|
147
|
+
const cursor = warmSearch!(source, plan.fields, plan.separator, job.cursor, 128);
|
|
148
|
+
if (plan.job !== job) return;
|
|
149
|
+
if (!cursor) {
|
|
150
|
+
// Unsupported shapes stay on the original path. A shrinking source (null)
|
|
151
|
+
// and thrown background errors remain eligible for a later attempt.
|
|
152
|
+
if (cursor === undefined) calls!.weakMapSet(plan.seen, source, false);
|
|
153
|
+
cancelSearch(plan);
|
|
154
|
+
} else if (cursor.complete) {
|
|
155
|
+
calls!.weakMapSet(plan.cache, source, { snapshot: cursor.snapshot, text: cursor.text, changes: 0 });
|
|
156
|
+
cancelSearch(plan);
|
|
157
|
+
} else {
|
|
158
|
+
job.cursor = cursor;
|
|
159
|
+
job.timer = schedule!(step, 0);
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// Background work must never turn a successful original search into an error.
|
|
163
|
+
if (plan.job === job) cancelSearch(plan);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
try { job.timer = schedule!(step, 0); } catch { cancelSearch(plan); }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sortPlan(source: string): SortPlan | undefined {
|
|
170
|
+
const i = intrinsics!;
|
|
171
|
+
const known = sorts && calls!.mapGet(sorts, source);
|
|
172
|
+
if (known) return known;
|
|
173
|
+
const raw = parsePlan(source);
|
|
174
|
+
if (!raw || typeof raw.date !== "string" || raw.direction !== 1 && raw.direction !== -1
|
|
175
|
+
|| raw.tie !== undefined && typeof raw.tie !== "string") return undefined;
|
|
176
|
+
const plan: SortPlan = { fields: typeof raw.tie === "string" ? [raw.date, raw.tie] : [raw.date],
|
|
177
|
+
direction: raw.direction, tie: typeof raw.tie === "string" };
|
|
178
|
+
if (!sorts || sortCount === maximumPlans) {
|
|
179
|
+
sorts = new i.Map<string, SortPlan>();
|
|
180
|
+
sortCount = 0;
|
|
181
|
+
}
|
|
182
|
+
calls!.mapSet(sorts, source, plan);
|
|
183
|
+
sortCount++;
|
|
184
|
+
return plan;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @internal The compiler supplies the original filter expression as the fallback. */
|
|
188
|
+
export function compiledSearch<T>(items: readonly T[], source: string, query: () => unknown, fallback: (items: readonly T[]) => T[]): T[] {
|
|
189
|
+
if (!intrinsics || !calls || !prepareSearch || !warmSearch || !schedule || !unschedule) return fallback(items);
|
|
190
|
+
let plan: SearchPlan | undefined;
|
|
191
|
+
try { plan = searchPlan(source); } catch { return fallback(items); }
|
|
192
|
+
if (!plan) return fallback(items);
|
|
193
|
+
if (plan.job && plan.job.items !== items) cancelSearch(plan);
|
|
194
|
+
const before = calls.weakMapGet(plan.cache, items);
|
|
195
|
+
if (!before) {
|
|
196
|
+
const result = fallback(items);
|
|
197
|
+
if (items !== null && (typeof items === "object" || typeof items === "function")) {
|
|
198
|
+
try {
|
|
199
|
+
const seen = calls.weakMapGet(plan.seen, items);
|
|
200
|
+
if (seen === true) warmLater(plan, items);
|
|
201
|
+
else if (seen === undefined) calls.weakMapSet(plan.seen, items, true);
|
|
202
|
+
} catch { /* Retaining a warming candidate is optional. */ }
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
const prepared = prepareSearch(items, plan.fields, before.snapshot, before.text, plan.separator, query, plan.queryLower);
|
|
207
|
+
if (!prepared) {
|
|
208
|
+
disableSearch(plan, items);
|
|
209
|
+
return fallback(items);
|
|
210
|
+
}
|
|
211
|
+
// The native guard leaves queries unevaluated for rejected or empty sources.
|
|
212
|
+
let result: T[];
|
|
213
|
+
// The guard returns current own row values without converting them.
|
|
214
|
+
if (prepared.items) result = prepared.items as T[];
|
|
215
|
+
else if (typeof prepared.needle === "string") {
|
|
216
|
+
// A query can change descriptors or species after preparation. Use the
|
|
217
|
+
// original filter operation with the already evaluated query in that case.
|
|
218
|
+
const needle = prepared.needle;
|
|
219
|
+
result = calls.filter(items, (_item: T, index: number) => calls.includes(prepared.text[index], needle));
|
|
220
|
+
} else return fallback(items);
|
|
221
|
+
const changes = prepared.snapshot === before.snapshot ? 0 : before.changes + 1;
|
|
222
|
+
if (changes >= 2) disableSearch(plan, items);
|
|
223
|
+
else calls.weakMapSet(plan.cache, items, { snapshot: prepared.snapshot, text: prepared.text, changes });
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** @internal Sort date keys once per invocation while retaining Array.sort's source identity. */
|
|
228
|
+
export function compiledSort<T>(items: T[], source: string, fallback: (items: T[]) => T[]): T[] {
|
|
229
|
+
if (!intrinsics || !calls || !snapshotFields) return fallback(items);
|
|
230
|
+
const plan = sortPlan(source);
|
|
231
|
+
if (!plan) return fallback(items);
|
|
232
|
+
const snapshot = snapshotFields(items, plan.fields, undefined, "sort");
|
|
233
|
+
if (!snapshot || items.length < 2) return fallback(items);
|
|
234
|
+
const width = plan.fields.length;
|
|
235
|
+
for (let offset = 0; offset < snapshot.length; offset += width) {
|
|
236
|
+
const date = snapshot[offset];
|
|
237
|
+
if (date !== null && typeof date === "object" || typeof date === "function" || typeof date === "symbol"
|
|
238
|
+
|| plan.tie && typeof snapshot[offset + 1] !== "string") return fallback(items);
|
|
239
|
+
}
|
|
240
|
+
const entries = calls.map(items, (item: T, index: number): SortEntry<T> => ({ item,
|
|
241
|
+
date: calls.dateParse(snapshot[index * width] ?? "") || 0,
|
|
242
|
+
tie: plan.tie ? snapshot[index * width + 1] as string : "" }));
|
|
243
|
+
calls.sort(entries, (a: SortEntry<T>, b: SortEntry<T>) => {
|
|
244
|
+
const order = plan.direction * (a.date - b.date);
|
|
245
|
+
return order || (plan.tie ? calls.localeCompare(a.tie, b.tie) : 0);
|
|
246
|
+
});
|
|
247
|
+
for (let index = 0; index < entries.length; index++) items[index] = entries[index].item;
|
|
248
|
+
return items;
|
|
249
|
+
}
|
package/src/image.ts
CHANGED
|
@@ -30,11 +30,25 @@ function RowComponent<const D extends Destination>({ image, title, titleMaxLines
|
|
|
30
30
|
|
|
31
31
|
export const Row = /* @__PURE__ */ memo(RowComponent) as typeof RowComponent;
|
|
32
32
|
|
|
33
|
+
export type ImageCrop = { zoom: number; x: number; y: number };
|
|
34
|
+
export type ImageOverlay = { src: string; x: number; y: number; width: number; height: number };
|
|
35
|
+
|
|
33
36
|
export function Image(props: {
|
|
34
37
|
src: string;
|
|
35
38
|
width: number;
|
|
36
39
|
height: number;
|
|
37
40
|
fit?: "contain" | "cover";
|
|
41
|
+
/** Clockwise quarter turns. Reuses one decoded texture across rotation and fit changes. */
|
|
42
|
+
rotation?: 0 | 90 | 180 | 270;
|
|
43
|
+
background?: string;
|
|
44
|
+
/** Normalised centre in the rotated source, with zoom relative to cover (1–4). */
|
|
45
|
+
crop?: ImageCrop;
|
|
46
|
+
/** Native gesture updates, delivered before subsequent press callbacks. */
|
|
47
|
+
onCropChange?: (crop: ImageCrop) => void;
|
|
48
|
+
/** Dashed horizontal guides at normalised frame positions (0–1), independent of crop. */
|
|
49
|
+
horizontalGuides?: readonly number[];
|
|
50
|
+
/** Transparent image in normalised frame coordinates, independent of crop and rotation. */
|
|
51
|
+
overlay?: ImageOverlay;
|
|
38
52
|
bleed?: boolean;
|
|
39
53
|
/** Fill the available content width, using width and height as the aspect ratio. */
|
|
40
54
|
fillWidth?: boolean;
|
package/src/index.ts
CHANGED
|
@@ -21,7 +21,6 @@ export function Screen({ header, children, leftAction, rightAction, ...props }:
|
|
|
21
21
|
centered?: boolean;
|
|
22
22
|
wide?: boolean;
|
|
23
23
|
bottomInset?: boolean;
|
|
24
|
-
waitForImages?: boolean;
|
|
25
24
|
leftAction?: { icon: IconAsset; onPress: () => void };
|
|
26
25
|
rightAction?: { icon: IconAsset; onPress: () => void };
|
|
27
26
|
}) {
|
|
@@ -91,10 +90,11 @@ export function Button<const D extends Destination>(props: PressProps<D> & {
|
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
export { Row, Image, Avatar } from "./image";
|
|
94
|
-
export { Canvas, Rectangle, CanvasText, CanvasIcon } from "./canvas";
|
|
93
|
+
export { Canvas, Rectangle, CanvasPath, CanvasRange, CanvasText, CanvasIcon } from "./canvas";
|
|
95
94
|
|
|
96
95
|
export function Field<const D extends Destination>(props: PressProps<D> & {
|
|
97
96
|
label: string;
|
|
97
|
+
disabled?: boolean;
|
|
98
98
|
children?: ReactNode;
|
|
99
99
|
}) {
|
|
100
100
|
const { href, url, onPress, ...rest } = props;
|
package/src/native-list.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createElement, Fragment, useLayoutEffect, useMemo, useRef, type ComponentProps, type ReactNode } from "./react";
|
|
1
|
+
import { createElement, Fragment, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "./react";
|
|
2
2
|
import { ListLoadError, ReactList, type ListProps } from "./list";
|
|
3
3
|
import { useAction } from "./action";
|
|
4
4
|
import type { Row } from "./image";
|
|
@@ -15,6 +15,8 @@ const keys = /* @__PURE__ */ new WeakMap<Function, string>();
|
|
|
15
15
|
const plainRecords = /* @__PURE__ */ new WeakSet<object>();
|
|
16
16
|
declare const __inkIsProxy: (value: unknown) => boolean;
|
|
17
17
|
const isProxy = typeof __inkIsProxy === "function" ? __inkIsProxy : undefined;
|
|
18
|
+
declare const __inkIsDenseArray: (value: unknown) => boolean;
|
|
19
|
+
const isDenseArray = typeof __inkIsDenseArray === "function" ? __inkIsDenseArray : undefined;
|
|
18
20
|
|
|
19
21
|
function bareFunctionPrototype(value: unknown, owner: Function): value is object {
|
|
20
22
|
if (value === null || typeof value !== "object" || isProxy?.(value)) return false;
|
|
@@ -99,21 +101,73 @@ function nativeValue(value: unknown, previous?: unknown): unknown {
|
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
type NativeItem = NativeListItem;
|
|
102
|
-
type
|
|
103
|
-
type Records<T> = { data: NativeItem[]; rows: Map<string, RowRecord<T>>; template: string;
|
|
104
|
+
type Records<T> = { data: NativeItem[]; positions: Map<string, number>; template: string;
|
|
104
105
|
items: readonly T[]; key: string | Function; measurementKey?: string | number; scope?: Scope };
|
|
106
|
+
const cooperativeListSize = 512;
|
|
107
|
+
const preparationBudget = 4;
|
|
108
|
+
const minimumPreparationRows = 32;
|
|
109
|
+
const maximumPreparationRows = 128;
|
|
110
|
+
type Preparation<T> = { steps: Generator<void, Records<T> | null>; result?: { value: Records<T> | null }; error?: { value: unknown } };
|
|
111
|
+
type Request<T> = { items: readonly T[]; key: string | Function; plan: Plan<T>;
|
|
112
|
+
measurementKey?: string | number; scope?: Scope; preparation: Preparation<T>; synchronous?: { value: Records<T> | null };
|
|
113
|
+
eventPositions: Map<string, number | undefined> };
|
|
114
|
+
type Prepared<T> = { request: Request<T>; generation: number } &
|
|
115
|
+
({ status: "ready"; value: Records<T> | null } | { status: "error"; error: unknown });
|
|
105
116
|
|
|
106
|
-
function
|
|
117
|
+
function listItem<T>(items: readonly T[], index: number): T {
|
|
118
|
+
const property = Object.getOwnPropertyDescriptor(items, index);
|
|
119
|
+
if (!property || !("value" in property)) throw needsReact;
|
|
120
|
+
return property.value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reproject only the items replaced in place, when every key stays at its position.
|
|
125
|
+
* Returns undefined when keys move, so the caller rebuilds the whole list.
|
|
126
|
+
*/
|
|
127
|
+
function* replaceInPlace<T>(previous: Records<T>, items: readonly T[], plan: Plan<T>,
|
|
128
|
+
keyExtractor: ListProps<T>["keyExtractor"], dense: boolean): Generator<void, Records<T> | undefined> {
|
|
129
|
+
let data: NativeItem[] | undefined;
|
|
130
|
+
const changes: NativeItem[] = [];
|
|
131
|
+
const length = items.length;
|
|
132
|
+
for (let index = 0; index < length; index++) {
|
|
133
|
+
const item = dense ? items[index] : listItem(items, index);
|
|
134
|
+
if (item === previous.items[index]) continue;
|
|
135
|
+
const before = previous.data[index];
|
|
136
|
+
if (!plainData(item)) throw needsReact;
|
|
137
|
+
if (keyExtractor(item, index) !== before.key) return undefined;
|
|
138
|
+
const projected = nativeValue(plan.project(item, index), before.values);
|
|
139
|
+
if (projected !== before.values) {
|
|
140
|
+
const record = { key: before.key, values: projected, measurementKey: before.measurementKey };
|
|
141
|
+
(data ??= previous.data.slice())[index] = record;
|
|
142
|
+
changes.push(record);
|
|
143
|
+
}
|
|
144
|
+
yield;
|
|
145
|
+
}
|
|
146
|
+
if (data) rememberListPatch(data, { previous: previous.data, changes });
|
|
147
|
+
return { ...previous, data: data ?? previous.data, items };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function captureValue(value: unknown, cache: WeakMap<object, Captured>, visiting = new Set<object>()): Captured {
|
|
107
151
|
if (value === null || typeof value !== "object" && typeof value !== "function") return { value };
|
|
108
152
|
const previous = cache.get(value);
|
|
109
|
-
if (previous) return previous;
|
|
153
|
+
if (previous && isProxy) return previous;
|
|
154
|
+
if (isProxy?.(value) || visiting.has(value) || visiting.size >= 64) throw needsReact;
|
|
155
|
+
const prototype = Object.getPrototypeOf(value);
|
|
156
|
+
if (prototype !== null && prototype !== Object.prototype && prototype !== Array.prototype
|
|
157
|
+
&& !(typeof value === "function" && prototype === Function.prototype)) throw needsReact;
|
|
158
|
+
visiting.add(value);
|
|
110
159
|
const properties: Record<PropertyKey, PropertyDescriptor> = Object.getOwnPropertyDescriptors(value);
|
|
111
|
-
const
|
|
160
|
+
const fields: NonNullable<Captured["fields"]> = [];
|
|
161
|
+
for (const key of Reflect.ownKeys(properties)) {
|
|
112
162
|
const property = properties[key];
|
|
163
|
+
if (!("value" in property)) throw needsReact;
|
|
164
|
+
if (["toString", "valueOf", Symbol.toPrimitive].includes(key) && typeof property.value === "function") throw needsReact;
|
|
113
165
|
const field = key === "prototype" && typeof value === "function" && bareFunctionPrototype(property.value, value)
|
|
114
|
-
? { value: property.value } : captureValue(property.value, cache);
|
|
115
|
-
|
|
116
|
-
}
|
|
166
|
+
? { value: property.value } : captureValue(property.value, cache, visiting);
|
|
167
|
+
fields.push([key, !!property.enumerable, field]);
|
|
168
|
+
}
|
|
169
|
+
visiting.delete(value);
|
|
170
|
+
const captured: Captured = { value, prototype, fields };
|
|
117
171
|
cache.set(value, captured);
|
|
118
172
|
return captured;
|
|
119
173
|
}
|
|
@@ -135,6 +189,99 @@ function sameScopeValues(before: readonly Captured[], after: readonly Captured[]
|
|
|
135
189
|
return before.length === after.length && after.every((value, index) => sameCapture(before[index], value, seen));
|
|
136
190
|
}
|
|
137
191
|
|
|
192
|
+
function captureScope<T>(plan: Plan<T>, items: readonly T[]): Scope | undefined {
|
|
193
|
+
if (!plan.scope) return;
|
|
194
|
+
const captured = items.length ? plan.scope.values() : [];
|
|
195
|
+
// Captures can mutate between slices; validate each fresh snapshot without the row cache.
|
|
196
|
+
return { identity: plan.scope.identity, values: captured.map(value => captureValue(value, new WeakMap())) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function sameScope(before?: Scope, after?: Scope): boolean {
|
|
200
|
+
return !!before && !!after && before.identity === after.identity && sameScopeValues(before.values, after.values);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function capturesMatch<T>(request: Request<T>, latestPlan: Plan<T> | null): boolean {
|
|
204
|
+
return !!latestPlan && sameScope(request.scope, captureScope(request.plan, request.items))
|
|
205
|
+
&& (latestPlan === request.plan || sameScope(request.scope, captureScope(latestPlan, request.items)));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function* prepareRecords<T>(previous: Records<T> | null, items: readonly T[], plan: Plan<T>,
|
|
209
|
+
keyExtractor: ListProps<T>["keyExtractor"], keyIdentity: string | Function,
|
|
210
|
+
measurementKey: string | number | undefined, scope: Scope | undefined, dense: boolean): Generator<void, Records<T> | null> {
|
|
211
|
+
try {
|
|
212
|
+
if (isProxy?.(items) || Object.getPrototypeOf(items) !== Array.prototype) throw needsReact;
|
|
213
|
+
const length = items.length;
|
|
214
|
+
const unchangedScope = sameScope(previous?.scope, scope);
|
|
215
|
+
if (previous && previous.items === items && previous.key === keyIdentity
|
|
216
|
+
&& previous.template === plan.template && previous.measurementKey === measurementKey
|
|
217
|
+
&& unchangedScope) return previous;
|
|
218
|
+
if (previous && unchangedScope && typeof keyIdentity === "string" && previous.key === keyIdentity
|
|
219
|
+
&& previous.template === plan.template && previous.measurementKey === measurementKey
|
|
220
|
+
&& previous.items.length === length) {
|
|
221
|
+
const patched = yield* replaceInPlace(previous, items, plan, keyExtractor, dense);
|
|
222
|
+
if (patched !== undefined) return { ...patched, scope };
|
|
223
|
+
}
|
|
224
|
+
const positions = new Map<string, number>();
|
|
225
|
+
let unchanged = length === previous?.data.length;
|
|
226
|
+
let reordered = !unchanged;
|
|
227
|
+
const changes: NativeItem[] = [];
|
|
228
|
+
let data: NativeItem[] = [];
|
|
229
|
+
const itemKeys: string[] = [];
|
|
230
|
+
for (let index = 0; index < length; index++) {
|
|
231
|
+
const item = dense ? items[index] : listItem(items, index);
|
|
232
|
+
if (!plainData(item)) throw needsReact;
|
|
233
|
+
const key = typeof keyIdentity === "string" && previous?.key === keyIdentity && previous.items[index] === item
|
|
234
|
+
? previous.data[index].key : keyExtractor(item, index);
|
|
235
|
+
if (typeof key !== "string" || positions.has(key)) throw new Error("List keys must be unique strings");
|
|
236
|
+
const beforeIndex = previous?.template === plan.template ? previous.positions.get(key) : undefined;
|
|
237
|
+
const before = previous && beforeIndex !== undefined ? previous.data[beforeIndex] : undefined;
|
|
238
|
+
const projected = previous && beforeIndex !== undefined && unchangedScope && previous.items[beforeIndex] === item
|
|
239
|
+
&& (beforeIndex === index || plan.scope?.usesIndex === false)
|
|
240
|
+
? previous.data[beforeIndex].values : nativeValue(plan.project(item, index), before?.values);
|
|
241
|
+
const measurement = measurementKey ?? null;
|
|
242
|
+
const record = before && before.values === projected && before.measurementKey === measurement
|
|
243
|
+
? before : { key, values: projected, measurementKey: measurement };
|
|
244
|
+
positions.set(key, index);
|
|
245
|
+
itemKeys.push(key);
|
|
246
|
+
if (record !== previous?.data[index]) {
|
|
247
|
+
unchanged = false;
|
|
248
|
+
if (record !== before) changes.push(record);
|
|
249
|
+
if (key !== previous?.data[index]?.key) reordered = true;
|
|
250
|
+
}
|
|
251
|
+
data.push(record);
|
|
252
|
+
yield;
|
|
253
|
+
}
|
|
254
|
+
if (unchanged && previous) data = previous.data;
|
|
255
|
+
else if (previous) rememberListPatch(data, { previous: previous.data, changes,
|
|
256
|
+
...(reordered ? { keys: itemKeys } : {}) });
|
|
257
|
+
return { data, positions, template: plan.template, items, key: keyIdentity, measurementKey, scope };
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error !== needsReact) throw error;
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function advancePreparation<T>(preparation: Preparation<T>, cooperative: boolean) {
|
|
265
|
+
if (preparation.result) return preparation.result;
|
|
266
|
+
if (preparation.error) throw preparation.error.value;
|
|
267
|
+
const started = cooperative ? performance.now() : 0;
|
|
268
|
+
let rows = 0;
|
|
269
|
+
while (true) {
|
|
270
|
+
let step;
|
|
271
|
+
try {
|
|
272
|
+
step = preparation.steps.next();
|
|
273
|
+
} catch (error) {
|
|
274
|
+
preparation.error = { value: error };
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
if (step.done) return preparation.result = { value: step.value };
|
|
278
|
+
rows++;
|
|
279
|
+
// Identity-only sparse scans do not yield: a single replaced row stays synchronous.
|
|
280
|
+
if (cooperative && (rows >= maximumPreparationRows
|
|
281
|
+
|| rows >= minimumPreparationRows && performance.now() - started >= preparationBudget)) return;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
138
285
|
export function nativeListPlan<T>(render: ListProps<T>["renderItem"]): Plan<T> | undefined {
|
|
139
286
|
return plans.get(render) as Plan<T> | undefined;
|
|
140
287
|
}
|
|
@@ -145,54 +292,45 @@ export function CompiledList<T>({ plan, ...props }: ListProps<T> & { plan: Plan<
|
|
|
145
292
|
const load = useAction(() => onLoadMore?.());
|
|
146
293
|
const older = useAction(() => onLoadOlder?.());
|
|
147
294
|
const committed = useRef<Records<T> | null>(null);
|
|
148
|
-
const
|
|
149
|
-
|
|
295
|
+
const committedRequest = useRef<Request<T> | null>(null);
|
|
296
|
+
const committedPlan = useRef<Plan<T> | null>(null);
|
|
297
|
+
const generation = useRef(0);
|
|
298
|
+
const [prepared, setPrepared] = useState<Prepared<T> | null>(null);
|
|
299
|
+
const request = useMemo<Request<T>>(() => {
|
|
150
300
|
const keyIdentity = keys.get(keyExtractor) ?? keyExtractor;
|
|
151
|
-
|
|
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;
|
|
301
|
+
let scope: Scope | undefined;
|
|
164
302
|
try {
|
|
165
|
-
|
|
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
|
-
});
|
|
303
|
+
scope = captureScope(plan, items);
|
|
186
304
|
} catch (error) {
|
|
187
305
|
if (error !== needsReact) throw error;
|
|
188
|
-
return null;
|
|
306
|
+
const preparation: Preparation<T> = { steps: (function* () { return null; })(), result: { value: null } };
|
|
307
|
+
return { items, key: keyIdentity, plan, measurementKey, preparation, synchronous: preparation.result, eventPositions: new Map() };
|
|
189
308
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
309
|
+
const pending = committedRequest.current;
|
|
310
|
+
if (pending && pending.items === items && pending.key === keyIdentity
|
|
311
|
+
&& pending.plan.template === plan.template && pending.measurementKey === measurementKey
|
|
312
|
+
&& sameScope(pending.scope, scope)) return pending;
|
|
313
|
+
const previous = committed.current;
|
|
314
|
+
const dense = isDenseArray?.(items) ?? false;
|
|
315
|
+
const preparation: Preparation<T> = { steps: prepareRecords(previous, items, plan, keyExtractor, keyIdentity, measurementKey, scope, dense) };
|
|
316
|
+
// First population must exist when List's one-shot initialPosition effect scrolls its Screen.
|
|
317
|
+
const cooperative = !!isProxy && !!scope && typeof keyIdentity === "string"
|
|
318
|
+
&& !!previous?.items.length && previous.template === plan.template && items.length >= cooperativeListSize;
|
|
319
|
+
const synchronous = advancePreparation(preparation, cooperative);
|
|
320
|
+
return { items, key: keyIdentity, plan, measurementKey, scope, preparation, synchronous, eventPositions: new Map() };
|
|
194
321
|
}, [items, keyExtractor, plan, measurementKey]);
|
|
195
|
-
|
|
322
|
+
const outcome = prepared?.request === request && prepared.generation === generation.current ? prepared : undefined;
|
|
323
|
+
let records = outcome?.status === "ready" ? outcome.value
|
|
324
|
+
: request.synchronous ? request.synchronous.value : committed.current;
|
|
325
|
+
if (outcome?.status === "ready" && !request.synchronous) {
|
|
326
|
+
try {
|
|
327
|
+
if (!capturesMatch(request, committedPlan.current)) records = null;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (error !== needsReact) throw error;
|
|
330
|
+
records = null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const preparing = !request.synchronous && outcome?.status !== "ready";
|
|
196
334
|
const targets = useMemo(() => {
|
|
197
335
|
type Template = { id: number; props: Record<string, unknown>; children: Template[] };
|
|
198
336
|
const targets = new Map<number, Record<string, (string | number)[]>>();
|
|
@@ -210,21 +348,88 @@ export function CompiledList<T>({ plan, ...props }: ListProps<T> & { plan: Plan<
|
|
|
210
348
|
visit(JSON.parse(plan.template) as Template);
|
|
211
349
|
return targets;
|
|
212
350
|
}, [plan.template]);
|
|
351
|
+
const events = useRef<{
|
|
352
|
+
items: readonly T[]; keyExtractor: ListProps<T>["keyExtractor"]; plan: Plan<T>;
|
|
353
|
+
targets: typeof targets; positions: Map<string, number | undefined>;
|
|
354
|
+
} | null>(null);
|
|
355
|
+
useLayoutEffect(() => {
|
|
356
|
+
committed.current = records;
|
|
357
|
+
committedRequest.current = request;
|
|
358
|
+
committedPlan.current = plan;
|
|
359
|
+
events.current = { items, keyExtractor, plan, targets, positions: request.eventPositions };
|
|
360
|
+
return () => {
|
|
361
|
+
events.current = null;
|
|
362
|
+
committedRequest.current = null;
|
|
363
|
+
committedPlan.current = null;
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
useLayoutEffect(() => {
|
|
367
|
+
const currentGeneration = ++generation.current;
|
|
368
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
369
|
+
const current = () => generation.current === currentGeneration && committedRequest.current === request;
|
|
370
|
+
const run = () => {
|
|
371
|
+
if (!current()) return;
|
|
372
|
+
try {
|
|
373
|
+
const result = capturesMatch(request, committedPlan.current)
|
|
374
|
+
? advancePreparation(request.preparation, true) : { value: null };
|
|
375
|
+
if (!current()) return;
|
|
376
|
+
if (result) setPrepared({ request, generation: currentGeneration, status: "ready", value: result.value });
|
|
377
|
+
else timer = setTimeout(run, 0);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (!current()) return;
|
|
380
|
+
setPrepared(error === needsReact
|
|
381
|
+
? { request, generation: currentGeneration, status: "ready", value: null }
|
|
382
|
+
: { request, generation: currentGeneration, status: "error", error });
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
if (!request.synchronous) timer = setTimeout(run, 0);
|
|
386
|
+
return () => {
|
|
387
|
+
generation.current++;
|
|
388
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
389
|
+
};
|
|
390
|
+
}, [request]);
|
|
391
|
+
if (outcome?.status === "error") throw outcome.error;
|
|
213
392
|
if (!records) return createElement(ReactList<T>, props);
|
|
214
393
|
const nativeProps = {
|
|
215
|
-
items: records.data, keyField: "key", template:
|
|
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,
|
|
394
|
+
items: records.data, keyField: "key", template: records.template, gap, followEnd, initialEnd: initialPosition === "end",
|
|
395
|
+
hasMore: !preparing && hasMore && load.status !== "pending" && load.status !== "error",
|
|
396
|
+
hasOlder: !preparing && hasOlder && older.status !== "pending" && older.status !== "error",
|
|
397
|
+
onEndReached: !preparing && onLoadMore ? load.run : undefined,
|
|
398
|
+
onStartReached: !preparing && onLoadOlder ? older.run : undefined,
|
|
220
399
|
onRowEvent(key: string, target: number, name: string, ...args: unknown[]) {
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
400
|
+
const latest = events.current;
|
|
401
|
+
const path = latest?.targets.get(target)?.[name];
|
|
402
|
+
if (!latest || !path || isProxy?.(latest.items)) return;
|
|
403
|
+
const matches = (index: number) => {
|
|
404
|
+
const property = Object.getOwnPropertyDescriptor(latest.items, index);
|
|
405
|
+
return !!property && "value" in property && plainData(property.value)
|
|
406
|
+
&& latest.keyExtractor(property.value, index) === key;
|
|
407
|
+
};
|
|
408
|
+
let index = latest.items === records.items ? records.positions.get(key) : latest.positions.get(key);
|
|
409
|
+
if (latest.items !== records.items && !latest.positions.has(key)) {
|
|
410
|
+
const beforeIndex = records.positions.get(key);
|
|
411
|
+
if (beforeIndex !== undefined && matches(beforeIndex)) index = beforeIndex;
|
|
412
|
+
else {
|
|
413
|
+
for (let candidate = 0; candidate < latest.items.length; candidate++) {
|
|
414
|
+
if (matches(candidate)) { index = candidate; break; }
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
latest.positions.set(key, index);
|
|
418
|
+
}
|
|
419
|
+
if (index === undefined) return;
|
|
420
|
+
let callback: unknown;
|
|
421
|
+
try {
|
|
422
|
+
const item = listItem(latest.items, index);
|
|
423
|
+
if (!plainData(item)) return;
|
|
424
|
+
callback = latest.plan.project(item, index);
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (error === needsReact) return;
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
for (const part of path) {
|
|
225
430
|
callback = callback != null && typeof callback === "object" ? Reflect.get(callback, part) : undefined;
|
|
226
431
|
}
|
|
227
|
-
if (
|
|
432
|
+
if (typeof callback === "function") callback(...args);
|
|
228
433
|
},
|
|
229
434
|
};
|
|
230
435
|
return createElement(Fragment, null,
|
package/src/renderer.ts
CHANGED
|
@@ -11,7 +11,7 @@ declare const __inkCommit: (operations: Operation[]) => void;
|
|
|
11
11
|
declare const __inkPost: (message: string) => void;
|
|
12
12
|
|
|
13
13
|
type Props = Record<string, unknown>;
|
|
14
|
-
type Instance = { id: number; type: string; props: Props; children: Instance[]; hidden?: boolean };
|
|
14
|
+
type Instance = { id: number; type: string; props: Props; children: Instance[]; parent: Container | undefined; hidden?: boolean };
|
|
15
15
|
type Container = { id: number; children: Instance[] };
|
|
16
16
|
type Operation =
|
|
17
17
|
| { op: "create"; id: number; type: string; props: Props }
|
|
@@ -86,16 +86,18 @@ function publish(instance: Instance) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
function insert(parent: Container, child: Instance, before?: Instance) {
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
parent.children.splice(
|
|
89
|
+
// Only a moved child needs searching for; appends stay constant-time.
|
|
90
|
+
if (child.parent === parent) parent.children.splice(parent.children.indexOf(child), 1);
|
|
91
|
+
child.parent = parent;
|
|
92
|
+
if (before) parent.children.splice(parent.children.indexOf(before), 0, child);
|
|
93
|
+
else parent.children.push(child);
|
|
93
94
|
publish(child);
|
|
94
95
|
queue({ op: "insert", id: child.id, parent: parent.id, before: before?.id ?? null });
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
function forget(instance: Instance) {
|
|
98
99
|
mounted.delete(instance.id);
|
|
100
|
+
instance.parent = undefined;
|
|
99
101
|
for (const child of instance.children) forget(child);
|
|
100
102
|
}
|
|
101
103
|
|
|
@@ -226,9 +228,10 @@ const reconciler = Reconciler<string, Props, Container, Instance, Instance, neve
|
|
|
226
228
|
supportsHydration: false,
|
|
227
229
|
isPrimaryRenderer: true,
|
|
228
230
|
supportsMicrotasks: true,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
// Declare `parent` up front so every instance keeps one object shape.
|
|
232
|
+
createInstance: (type, props) => ({ id: allocateHostId(), type, props: hostProps(type, props), children: [], parent: undefined }),
|
|
233
|
+
createTextInstance: text => ({ id: allocateHostId(), type: "#text", props: { text }, children: [], parent: undefined }),
|
|
234
|
+
appendInitialChild: (parent, child) => { parent.children.push(child); child.parent = parent; },
|
|
232
235
|
finalizeInitialChildren: () => false,
|
|
233
236
|
shouldSetTextContent: (type, props) => {
|
|
234
237
|
if (type !== "Text") return false;
|
package/src/snapshot.ts
CHANGED
|
@@ -13,6 +13,6 @@ export interface SnapshotSource<T> {
|
|
|
13
13
|
|
|
14
14
|
export function useSnapshot<T>(source: ResourceSource<T>): ResourceSnapshot<T>;
|
|
15
15
|
export function useSnapshot<T>(source: SnapshotSource<T>): Snapshot<T>;
|
|
16
|
-
export function useSnapshot
|
|
16
|
+
export function useSnapshot(source: { subscribe(listener: () => void): () => void; getSnapshot(): unknown }): unknown {
|
|
17
17
|
return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot);
|
|
18
18
|
}
|