lecodes-sdk 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/global.d.ts +8 -0
- package/dist/types/core/signals.d.ts +6 -0
- package/dist/types/inject.d.ts +1 -0
- package/dist/types/runtime/resource.d.ts +49 -0
- package/dist/types/server/db/marci/query.d.ts +4 -0
- package/dist/types/server/db/types.d.ts +3 -3
- package/dist/types/server/files/models.d.ts +7 -0
- package/dist/types/ui/UIVirtualizedList.d.ts +13 -20
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +2 -2
- package/prompts/core.md +1 -0
- package/prompts/ui.md +3 -5
- package/src/chisel.ts +5 -2
- package/src/core/signals.ts +28 -0
- package/src/inject.ts +2 -0
- package/src/runtime/resource.ts +96 -0
- package/src/server/db/marci/query.ts +9 -1
- package/src/server/db/types.ts +3 -3
- package/src/server/files/models.ts +5 -0
- package/src/server/host.ts +5 -2
- package/src/ui/UIVirtualizedList.ts +47 -68
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"prepublishOnly": "bun run build"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@letary/chisel": "^0.
|
|
38
|
+
"@letary/chisel": "^0.14.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/bun": "^1.3.14",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"marcidb-embedded": "^0.14.1",
|
|
46
46
|
"sharp": "^0.35.2"
|
|
47
47
|
},
|
|
48
|
-
"version": "2.
|
|
48
|
+
"version": "2.2.0",
|
|
49
49
|
"files": [
|
|
50
50
|
"src",
|
|
51
51
|
"dist",
|
package/prompts/core.md
CHANGED
|
@@ -229,6 +229,7 @@ new WebSocket(url, headers?: Record<string, string>)
|
|
|
229
229
|
// ===== STORAGE / DEVICE / MISC =====
|
|
230
230
|
|
|
231
231
|
localStorage.getItem(key): string | null / .setItem(key, value) / .removeItem(key) // nothing else
|
|
232
|
+
signal<Line[]>([]).stored("cart") // a signal kept on the device under the key (JSON values): back on the next launch
|
|
232
233
|
|
|
233
234
|
device.platform: "ios" | "android" | "web" // getter, no ()
|
|
234
235
|
device.language: string // "en", "ru", …
|
package/prompts/ui.md
CHANGED
|
@@ -125,17 +125,15 @@ UIScrollable(...children)
|
|
|
125
125
|
// visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
|
|
126
126
|
// is large, grows over time, or is unknown.
|
|
127
127
|
UIVirtualizedList<T>({
|
|
128
|
+
data: () => T[], // the rows, read reactively: write the signal, the list follows (new array, keys matched)
|
|
128
129
|
keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
|
|
129
130
|
render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
|
|
130
131
|
estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
|
|
131
132
|
overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
|
|
132
133
|
inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
|
|
133
134
|
})
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// .append(...items) / .prepend(...items) // variadic; append = newest chat msg; prepend = history, no scroll jump
|
|
137
|
-
// .update(...items) // re-render rows with the same keyOf(); unknown keys ignored
|
|
138
|
-
// .removeByKey(...keys)
|
|
135
|
+
// Rows added at the end scroll a chat at its bottom; rows added before the first (history) move nothing; a row
|
|
136
|
+
// whose item is a new object re-renders. Never re-create the list.
|
|
139
137
|
// .itemCount (getter) / .getItem(key)
|
|
140
138
|
// .scrollTo(offset, animated?) / .scrollToKey(key, animated?) / .scrollToEnd(animated?) / .onScroll(cb(pos))
|
|
141
139
|
// .onEndReached(thresholdPx: number, cb) // threshold FIRST and required; fires near the BOTTOM — load next page
|
package/src/chisel.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface ChiselVocab {
|
|
|
48
48
|
signals: {
|
|
49
49
|
/** Calls that create a signal (the roots of chisel's signal-type inference). */
|
|
50
50
|
factories: string[]
|
|
51
|
+
/** Methods of a signal that return it (`signal(0).stored("key")`): a call of one on a signal is a signal. */
|
|
52
|
+
chain: string[]
|
|
51
53
|
/** The property a signal is read through. */
|
|
52
54
|
value: string
|
|
53
55
|
}
|
|
@@ -131,8 +133,8 @@ export const chiselVocab: ChiselVocab = {
|
|
|
131
133
|
"append", "insert", "remove", "setContent", "keepAlive", "url",
|
|
132
134
|
"detents", "dismissible", "transition", "setSourceRect",
|
|
133
135
|
"push", "pop", "popToRoot", "replace", "select",
|
|
134
|
-
// UITabs /
|
|
135
|
-
"badge", "setData",
|
|
136
|
+
// UITabs / the native views (NativeView, CameraView, MapView, QRScanner)
|
|
137
|
+
"badge", "setData",
|
|
136
138
|
"on", "off", "onReady", "onError", "onTap", "onMove", "onScan",
|
|
137
139
|
"flyTo", "jumpTo", "fitPoints", "setPadding", "setUserLocation",
|
|
138
140
|
],
|
|
@@ -142,6 +144,7 @@ export const chiselVocab: ChiselVocab = {
|
|
|
142
144
|
},
|
|
143
145
|
signals: {
|
|
144
146
|
factories: [ "signal", "computed" ],
|
|
147
|
+
chain: [ "stored" ],
|
|
145
148
|
value: "value",
|
|
146
149
|
},
|
|
147
150
|
fusion: {
|
package/src/core/signals.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
// fallback for engines without it (then stale subscribers just live until the run ends —
|
|
18
18
|
// every run executes in a fresh JSRuntime anyway).
|
|
19
19
|
|
|
20
|
+
import { localStorage } from "../runtime/storage"
|
|
21
|
+
|
|
20
22
|
interface WeakRefLike<T extends object> { deref(): T | undefined }
|
|
21
23
|
|
|
22
24
|
const WeakRefImpl: new <T extends object>(target: T) => WeakRefLike<T> =
|
|
@@ -150,6 +152,12 @@ export interface Signal<T> {
|
|
|
150
152
|
value: T,
|
|
151
153
|
/** Read without subscribing the current effect/computed. */
|
|
152
154
|
peek(): T,
|
|
155
|
+
/**
|
|
156
|
+
* Kept on the device under `key`: what the last run left there replaces the initial value (when
|
|
157
|
+
* it is there and reads as JSON), and every write is saved. JSON values only — a date, a class
|
|
158
|
+
* instance or a function does not survive the round trip. `signal<Line[]>([]).stored("cart")`.
|
|
159
|
+
*/
|
|
160
|
+
stored(key: string): Signal<T>,
|
|
153
161
|
}
|
|
154
162
|
|
|
155
163
|
export interface Computed<T> {
|
|
@@ -161,6 +169,7 @@ export interface Computed<T> {
|
|
|
161
169
|
class SignalImpl<T> {
|
|
162
170
|
private _dep = new Dep()
|
|
163
171
|
private _v: T
|
|
172
|
+
private _key?: string
|
|
164
173
|
|
|
165
174
|
constructor(value: T) {
|
|
166
175
|
this._v = value
|
|
@@ -174,12 +183,31 @@ class SignalImpl<T> {
|
|
|
174
183
|
set value(next: T) {
|
|
175
184
|
if (Object.is(next, this._v)) return
|
|
176
185
|
this._v = next
|
|
186
|
+
if (this._key !== undefined) this._save()
|
|
177
187
|
this._dep._notify()
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
peek(): T {
|
|
181
191
|
return this._v
|
|
182
192
|
}
|
|
193
|
+
|
|
194
|
+
stored(key: string): Signal<T> {
|
|
195
|
+
this._key = key
|
|
196
|
+
let raw: string | null = null
|
|
197
|
+
try { raw = localStorage.getItem(key) } catch { /* a host without storage: the value lives for this run */ }
|
|
198
|
+
if (raw !== null) {
|
|
199
|
+
try { this.value = JSON.parse(raw) as T } catch { /* not what this app wrote: the initial value stands */ }
|
|
200
|
+
}
|
|
201
|
+
return this
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Saved as it is written, so what the device keeps never lags behind the screen (no effect, no flush).
|
|
205
|
+
private _save(): void {
|
|
206
|
+
try {
|
|
207
|
+
if (this._v === undefined) localStorage.removeItem(this._key!)
|
|
208
|
+
else localStorage.setItem(this._key!, JSON.stringify(this._v))
|
|
209
|
+
} catch { /* storage-less host, or a value JSON cannot carry: the screen is right, the device forgets */ }
|
|
210
|
+
}
|
|
183
211
|
}
|
|
184
212
|
|
|
185
213
|
class ComputedImpl<T> implements Computation {
|
package/src/inject.ts
CHANGED
|
@@ -43,6 +43,8 @@ export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, t
|
|
|
43
43
|
// App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
|
|
44
44
|
// only ever imports its server functions (docs/backend-plan.md §4).
|
|
45
45
|
export { __rpc, __channel, __serverOnly, RpcError } from "./runtime/rpc"
|
|
46
|
+
// What a screen asks of the server, as reactive state: a request a screen follows, an action with its own busy / error.
|
|
47
|
+
export { resource, action, dataOf, failureOf, type Loaded, type Failure, type Resource, type Action } from "./runtime/resource"
|
|
46
48
|
export type { ChannelSubscription } from "./server/channel"
|
|
47
49
|
export type { StoredFile, StoredImage } from "./server/files/models"
|
|
48
50
|
export { AudioPlayer, VideoPlayer } from "./runtime/media"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// What a screen asks of the app's server, as reactive state: a `resource` is a request a screen
|
|
2
|
+
// follows (loading, ready, failed — ONE value), an `action` is a call with its own busy / error —
|
|
3
|
+
// no try around a call, no flags by hand. Both take the compiled server stubs of ./rpc.ts, so a
|
|
4
|
+
// failure is an RpcError: its status is the server's ApiError's, 0 when the request never reached
|
|
5
|
+
// a server (no network, no backend) — `offline`.
|
|
6
|
+
|
|
7
|
+
import { effect, signal, type Computed } from "../core/signals"
|
|
8
|
+
|
|
9
|
+
/** Why a call failed: the server's refusal in its words — or `offline`, the request never reached
|
|
10
|
+
* it (then `message` is the transport's note, not something to show; say it in your own words). */
|
|
11
|
+
export type Failure = { readonly message: string, readonly offline: boolean }
|
|
12
|
+
|
|
13
|
+
/** What was asked of the server, at any moment: one value a screen draws in its three states. */
|
|
14
|
+
export type Loaded<T> =
|
|
15
|
+
| { readonly state: "loading" }
|
|
16
|
+
| { readonly state: "ready", readonly data: T }
|
|
17
|
+
| ({ readonly state: "failed" } & Failure)
|
|
18
|
+
|
|
19
|
+
/** The data once it is there; `otherwise` until then. */
|
|
20
|
+
export const dataOf = <T>(loaded: Loaded<T>, otherwise: T): T => loaded.state === "ready" ? loaded.data : otherwise
|
|
21
|
+
|
|
22
|
+
/** The failure, or null while there is none. */
|
|
23
|
+
export const failureOf = <T>(loaded: Loaded<T>): Failure | null => loaded.state === "failed" ? loaded : null
|
|
24
|
+
|
|
25
|
+
const failure = (error: unknown): Failure => ({
|
|
26
|
+
message: error instanceof Error ? error.message : String(error),
|
|
27
|
+
offline: (error as { status?: unknown } | null)?.status === 0,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export interface Resource<T> {
|
|
31
|
+
/** The request now — read in an effect, a computed or a binding, the screen follows it. */
|
|
32
|
+
readonly value: Loaded<T>
|
|
33
|
+
/** Read without subscribing. */
|
|
34
|
+
peek(): Loaded<T>
|
|
35
|
+
/** Ask again. What is shown stays until the answer. */
|
|
36
|
+
reload(): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What `load` answers, as a `Loaded` signal: asked at once, again when a signal `load` read changes
|
|
41
|
+
* and on `reload()`. An answer that arrives after a newer request was made is dropped. While the
|
|
42
|
+
* data is asked for again, what is there stays. With `initial` — rows the bundle carries — the data
|
|
43
|
+
* is ready from the start and the server's answer replaces it; a failure then leaves it as it is,
|
|
44
|
+
* since what is shown is real.
|
|
45
|
+
*/
|
|
46
|
+
export function resource<T>(load: () => Promise<T>, options: { initial?: T } = {}): Resource<T> {
|
|
47
|
+
const loaded = signal<Loaded<T>>(options.initial !== undefined ? { state: "ready", data: options.initial } : { state: "loading" })
|
|
48
|
+
const asked = signal(0)
|
|
49
|
+
let latest = 0
|
|
50
|
+
effect(() => {
|
|
51
|
+
asked.value
|
|
52
|
+
const mine = ++latest
|
|
53
|
+
let request: Promise<T>
|
|
54
|
+
try { request = load() } catch (error) { request = Promise.reject(error) }
|
|
55
|
+
if (loaded.peek().state !== "ready") loaded.value = { state: "loading" }
|
|
56
|
+
request.then(
|
|
57
|
+
(data) => { if (mine === latest) loaded.value = { state: "ready", data } },
|
|
58
|
+
(error) => { if (mine === latest && !(options.initial !== undefined && loaded.peek().state === "ready")) loaded.value = { state: "failed", ...failure(error) } },
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
return {
|
|
62
|
+
get value() { return loaded.value },
|
|
63
|
+
peek: () => loaded.peek(),
|
|
64
|
+
reload: () => { asked.value = asked.peek() + 1 },
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type Action<A extends unknown[], R> = ((...args: A) => Promise<R | null>) & {
|
|
69
|
+
/** True while a call runs; a call made meanwhile is ignored (it resolves to null). */
|
|
70
|
+
readonly busy: Computed<boolean>
|
|
71
|
+
/** Why the last call failed; null when it did not. Cleared as a call starts. */
|
|
72
|
+
readonly error: Computed<Failure | null>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Something the person does that the server must carry out. Resolves to what the server answered,
|
|
77
|
+
* or null when the call failed (then `error` says why) or was ignored while busy.
|
|
78
|
+
*/
|
|
79
|
+
export function action<A extends unknown[], R>(run: (...args: A) => Promise<R>): Action<A, R> {
|
|
80
|
+
const busy = signal(false)
|
|
81
|
+
const error = signal<Failure | null>(null)
|
|
82
|
+
const call = async (...args: A): Promise<R | null> => {
|
|
83
|
+
if (busy.peek()) return null
|
|
84
|
+
busy.value = true
|
|
85
|
+
error.value = null
|
|
86
|
+
try {
|
|
87
|
+
return await run(...args)
|
|
88
|
+
} catch (thrown) {
|
|
89
|
+
error.value = failure(thrown)
|
|
90
|
+
return null
|
|
91
|
+
} finally {
|
|
92
|
+
busy.value = false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return Object.assign(call, { busy, error })
|
|
96
|
+
}
|
|
@@ -258,6 +258,8 @@ export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Promise
|
|
|
258
258
|
aggregate<A extends T["aggregate"]>(query: A): Op<AggregateResult<T["model"], A>> & Sub<T["name"], NoInfer<A>>
|
|
259
259
|
|
|
260
260
|
insert(data: T["insert"]): Op<T["id"]>
|
|
261
|
+
/** The new row by `select`'s shape — its defaults, dates and files as stored: one call, nothing to read back. */
|
|
262
|
+
insert<S extends T["query"]>(data: T["insert"], options: { select: S }): Promise<Rows<T, S>>
|
|
261
263
|
update(id: IdArg<T["id"]>, data: T["update"]): Op<void>
|
|
262
264
|
/** Applies `data` to every row the chain's `where` matches (all rows without one); resolves to the number of rows. */
|
|
263
265
|
updateMany(data: T["update"]): Op<number>
|
|
@@ -454,7 +456,13 @@ export function createQueryLayer(options: QueryLayerOptions): { op: (descriptor:
|
|
|
454
456
|
return Object.assign(op({ model, action: "aggregate", query: q }), { __select: q });
|
|
455
457
|
},
|
|
456
458
|
|
|
457
|
-
insert: (data: any
|
|
459
|
+
insert: (data: any, extra?: { select?: Record<string, any> }) => {
|
|
460
|
+
const inserted = op({ model, action: "insert", data: options.prepareInsert ? options.prepareInsert(model, data) : data });
|
|
461
|
+
if (!extra?.select) return inserted;
|
|
462
|
+
// the row as stored, read back by the id the engine answered — a plain promise: a read is not a $transaction member
|
|
463
|
+
return inserted.then((id: any) => run({ model, action: "findFirst", query: resolveShape(model, { ...extra.select, $where: id }) }))
|
|
464
|
+
.then((row: any) => { if (row === null) throw new Error(`marcidb: ${model}: the row just inserted is gone`); return row; });
|
|
465
|
+
},
|
|
458
466
|
update: (id: any, data: any) => op({ model, action: "update", id: idObject(model, id), data }),
|
|
459
467
|
// `updateMany(data)`; the deprecated `updateMany(query, data)` form is told apart by arity
|
|
460
468
|
updateMany: (a: any, b?: any) => (b === undefined
|
package/src/server/db/types.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { Field, FieldMeta } from "./fields"
|
|
11
11
|
import type { IdentityPublicFields, SessionPublicFields } from "../auth/models"
|
|
12
|
-
import type { FileField,
|
|
12
|
+
import type { FileField, FileWrite, StoredFile } from "../files/models"
|
|
13
13
|
import type {
|
|
14
14
|
CompareNumValue, CompareRefListValue, CompareRefValue, CompareStrValue, CompareValue, CustomSearchValue,
|
|
15
15
|
FullTextSearch, JsonCondition, JsonPathWhere, Op, PrimitiveListUpdate, Query as MarciQuery, RefListUpdate,
|
|
@@ -72,7 +72,7 @@ type FieldInsert<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
|
|
|
72
72
|
M["kind"] extends "one" ? Nullable<M, Id<S, ModelName<S, Fld>>>
|
|
73
73
|
: M["kind"] extends "many" | "list" ? Id<S, ModelName<S, Fld>>[]
|
|
74
74
|
: M["kind"] extends "struct" ? Nullable<M, StructInsert<S, StructFieldsOf<Fld>>>
|
|
75
|
-
: M["kind"] extends "file" ? Nullable<M, Arr<M,
|
|
75
|
+
: M["kind"] extends "file" ? Nullable<M, Arr<M, FileWrite>> // a new row takes uploads
|
|
76
76
|
: Nullable<M, Arr<M, TsOf<Fld>>>
|
|
77
77
|
|
|
78
78
|
type RequiredKeys<F extends Fields> = {
|
|
@@ -99,7 +99,7 @@ type FieldUpdate<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
|
|
|
99
99
|
: M["kind"] extends "list" ? RefListUpdateOrdered<Id<S, ModelName<S, Fld>>>
|
|
100
100
|
: M["kind"] extends "struct" ? RefUpdateStruct<StructInsert<S, StructFieldsOf<Fld>>, UpdateOf<S, StructFieldsOf<Fld>>> | null
|
|
101
101
|
// an upload replaces; the stored file the field holds, handed back, keeps it; null / a shorter list deletes
|
|
102
|
-
: M["kind"] extends "file" ? Nullable<M, Arr<M,
|
|
102
|
+
: M["kind"] extends "file" ? Nullable<M, Arr<M, FileWrite | StoredFile>>
|
|
103
103
|
: M["array"] extends true ? TsOf<Fld>[] | PrimitiveListUpdate<TsOf<Fld>>
|
|
104
104
|
: Nullable<M, TsOf<Fld> | (IsNum<M> extends true ? UpdateNumValue : never)>
|
|
105
105
|
|
|
@@ -43,6 +43,11 @@ export type StoredImage = StoredFile & { width: number, height: number }
|
|
|
43
43
|
*/
|
|
44
44
|
export type UploadedFile = { readonly name: string, readonly size: number, readonly type: string }
|
|
45
45
|
|
|
46
|
+
/** What a file field is written with. A project's own type check sees the endpoint's `File` as the
|
|
47
|
+
* app's (global.d.ts: a name and a size — the type is read from the bytes on arrival), so the write
|
|
48
|
+
* asks for no more; what arrives is an `UploadedFile`. */
|
|
49
|
+
export type FileWrite = { readonly name: string, readonly size: number }
|
|
50
|
+
|
|
46
51
|
/** One `t.file()` field of the schema; `image` = it is a `t.image()`, with its rule. */
|
|
47
52
|
export type FileField = { model: string, key: string, array: boolean, owner: string, image?: ImageRule }
|
|
48
53
|
|
package/src/server/host.ts
CHANGED
|
@@ -83,9 +83,12 @@ export const describeServer = (server: LoadedServer): ServerDescription => {
|
|
|
83
83
|
for (const [name, m] of Object.entries(db.$models as Record<string, Model>)) {
|
|
84
84
|
const fields = Object.entries(m.fields as Record<string, Field<any, any>>).filter(([k]) => !shown[name] || shown[name].includes(k))
|
|
85
85
|
const described = Object.fromEntries(fields.map(([k, f]) => [k, describeField(f)]))
|
|
86
|
-
// a file field is, in the database, a relation to File: what
|
|
86
|
+
// a file field is, in the database, a relation to File: what the project declared is said here —
|
|
87
|
+
// whether the row may have none, and the picture's rule when it is a t.image()
|
|
87
88
|
for (const f of files) {
|
|
88
|
-
if (f.model
|
|
89
|
+
if (f.model !== name) continue
|
|
90
|
+
const declared = (db.$given[name]?.fields as Record<string, Field<any, any>> | undefined)?.[f.key]?.def
|
|
91
|
+
described[f.key] = { kind: "file", optional: declared?.optional ?? false, unique: false, index: false, hasDefault: false, array: f.array, isId: false, ...(f.image ? { image: f.image } : {}) }
|
|
89
92
|
}
|
|
90
93
|
models[name] = { fields: described, ...(name === FILE_MODEL ? { files: true as const } : shown[name] ? { auth: true as const } : {}) }
|
|
91
94
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createBinding } from "../core/signals"
|
|
1
2
|
import { Element, type Color, type DrawableStyle, type ElementStyle, type UIElementBase, type UINodeChild } from "./UINode"
|
|
2
3
|
import { TREE_FLAG_REFRESH, TREE_FLAG_SCROLL, TREE_FLAG_VLIST_END, TREE_FLAG_VLIST_START, pin, tree, unpin } from "./tree"
|
|
3
4
|
|
|
@@ -10,6 +11,11 @@ export type UIVirtualizedListStyle = DrawableStyle & ElementStyle & {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export interface VListConfig<T> {
|
|
14
|
+
/** The rows, read reactively: the list follows the signals this reads, the way a container's
|
|
15
|
+
* function children do. Rows are told apart by `keyOf`; a row whose item is another object
|
|
16
|
+
* re-renders. Appended rows and rows added at the start are told from a rewrite (a chat at
|
|
17
|
+
* its bottom follows what is appended; older history above moves nothing). */
|
|
18
|
+
data: () => T[]
|
|
13
19
|
/** Stable unique key of an item. */
|
|
14
20
|
keyOf: (item: T) => string
|
|
15
21
|
/** Subtree factory — called when the item enters the window; the subtree lives until it
|
|
@@ -27,22 +33,10 @@ export interface VListConfig<T> {
|
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/** A windowed (virtualized) list: only the items near the viewport are mounted — for long feeds
|
|
30
|
-
* and chats.
|
|
36
|
+
* and chats. Fed by `data`, a function over signals. */
|
|
31
37
|
export interface UIVirtualizedList<T = unknown> extends UIElementBase<UIVirtualizedListStyle> {
|
|
32
38
|
readonly type: "vlist"
|
|
33
39
|
|
|
34
|
-
/** Replace the data wholesale — vanished keys are unmounted by the window sync. */
|
|
35
|
-
setData(items: T[]): this
|
|
36
|
-
/** Append items to the end (auto-scrolls when `inverted` and resting at the bottom). */
|
|
37
|
-
append(...items: T[]): this
|
|
38
|
-
/** Insert at the start WITHOUT a scroll jump (the runtime compensates the offset). */
|
|
39
|
-
prepend(...items: T[]): this
|
|
40
|
-
/** Update items with the same keys: mounted ones re-render; the cached height persists until
|
|
41
|
-
* re-measured. */
|
|
42
|
-
update(...items: T[]): this
|
|
43
|
-
/** Remove items by key. */
|
|
44
|
-
removeByKey(...keys: string[]): this
|
|
45
|
-
|
|
46
40
|
/** Number of data items (mounted or not). */
|
|
47
41
|
readonly itemCount: number
|
|
48
42
|
/** The data item under `key`, if any. */
|
|
@@ -72,6 +66,7 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
|
|
|
72
66
|
private readonly strl: (() => void)[] = [] // startReached
|
|
73
67
|
private _refreshCallback?: () => void | Promise<void>
|
|
74
68
|
|
|
69
|
+
private keys: string[] = []
|
|
75
70
|
private items = new Map<string, T>()
|
|
76
71
|
private mounted = new Map<string, any>()
|
|
77
72
|
private keyOf: (item: T) => string
|
|
@@ -95,77 +90,61 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
|
|
|
95
90
|
: () => config.estimatedHeight as number
|
|
96
91
|
this._overscan = config.overscan ?? -1
|
|
97
92
|
this._inverted = config.inverted ?? false
|
|
93
|
+
// The data binding is the element's (it goes with it); its first run waits a microtask so the
|
|
94
|
+
// edge thresholds chained after the constructor (`.onEndReached(…)`) are in the runtime's list
|
|
95
|
+
// state, which is created on the first write.
|
|
96
|
+
Promise.resolve().then(() => { if (this._h.id !== 0) createBinding(this, "data", () => this._apply(config.data())) })
|
|
98
97
|
}
|
|
99
98
|
|
|
100
99
|
// The runtime's list state is created on the first data write — after the app had its chance to
|
|
101
|
-
// set the edge thresholds
|
|
100
|
+
// set the edge thresholds.
|
|
102
101
|
private _ensure(): void {
|
|
103
102
|
if (this._inited || this._h.id === 0) return
|
|
104
103
|
this._inited = true
|
|
105
104
|
tree().vlistInit(this._h.id, this._overscan, this._inverted, this._startThreshold, this._endThreshold)
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
|
|
107
|
+
/** The data as it is now against what the runtime holds: rows added at the end or at the start
|
|
108
|
+
* are inserts (the runtime anchors the scroll: a chat follows an append, history above moves
|
|
109
|
+
* nothing), rows gone are removals, anything else a rewrite of the keys (measured heights stay). */
|
|
110
|
+
private _apply(next: T[]): void {
|
|
111
|
+
const prevKeys = this.keys
|
|
112
|
+
const prevItems = this.items
|
|
109
113
|
const keys: string[] = []
|
|
110
114
|
const estimates: number[] = []
|
|
111
|
-
|
|
115
|
+
const items = new Map<string, T>()
|
|
116
|
+
for (const item of next) {
|
|
112
117
|
const k = this.keyOf(item)
|
|
113
|
-
|
|
118
|
+
if (items.has(k)) continue // a repeated key: the first row stands
|
|
119
|
+
items.set(k, item)
|
|
114
120
|
keys.push(k)
|
|
115
121
|
estimates.push(this.estimate(item))
|
|
116
122
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
setData(items: T[]): this {
|
|
121
|
-
const prevItems = new Map(this.items)
|
|
122
|
-
const prevMounted = [...this.mounted.keys()]
|
|
123
|
-
this.items.clear()
|
|
124
|
-
const [keys, estimates] = this.register(items)
|
|
125
|
-
this._ensure()
|
|
126
|
-
if (this._h.id !== 0) {
|
|
127
|
-
tree().vlistSetKeys(this._h.id, keys, estimates)
|
|
128
|
-
for (const k of prevMounted) {
|
|
129
|
-
if (this.mounted.has(k) && this.items.get(k) !== prevItems.get(k)) {
|
|
130
|
-
tree().vlistInvalidate(this._h.id, k)
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return this
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
append(...items: T[]): this {
|
|
138
|
-
const [keys, estimates] = this.register(items)
|
|
139
|
-
this._ensure()
|
|
140
|
-
if (this._h.id !== 0) tree().vlistInsertKeys(this._h.id, -1, keys, estimates)
|
|
141
|
-
return this
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
prepend(...items: T[]): this {
|
|
145
|
-
const [keys, estimates] = this.register(items)
|
|
123
|
+
this.keys = keys
|
|
124
|
+
this.items = items
|
|
146
125
|
this._ensure()
|
|
147
|
-
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
126
|
+
const id = this._h.id
|
|
127
|
+
if (id === 0) return
|
|
128
|
+
const n = prevKeys.length, m = keys.length
|
|
129
|
+
const same = (from: number) => { for (let i = 0; i < n; i++) if (keys[from + i] !== prevKeys[i]) return false; return true }
|
|
130
|
+
if (n === 0 || m === 0) {
|
|
131
|
+
tree().vlistSetKeys(id, keys, estimates)
|
|
132
|
+
} else if (m >= n && same(0)) {
|
|
133
|
+
if (m > n) tree().vlistInsertKeys(id, -1, keys.slice(n), estimates.slice(n)) // the same keys: nothing to tell
|
|
134
|
+
} else if (m > n && same(m - n)) {
|
|
135
|
+
tree().vlistInsertKeys(id, 0, keys.slice(0, m - n), estimates.slice(0, m - n))
|
|
136
|
+
} else {
|
|
137
|
+
// removals only: the new keys are the old ones in order, some left out
|
|
138
|
+
const gone: string[] = []
|
|
139
|
+
let j = 0
|
|
140
|
+
for (const k of prevKeys) { if (j < m && keys[j] === k) j++; else gone.push(k) }
|
|
141
|
+
if (m < n && j === m) tree().vlistRemoveKeys(id, gone)
|
|
142
|
+
else tree().vlistSetKeys(id, keys, estimates)
|
|
157
143
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
removeByKey(...keys: string[]): this {
|
|
162
|
-
const gone: string[] = []
|
|
163
|
-
for (const k of keys) {
|
|
164
|
-
if (!this.items.delete(k)) continue
|
|
165
|
-
gone.push(k)
|
|
144
|
+
// a mounted row whose item is another object re-renders; the others pick theirs up when they re-enter
|
|
145
|
+
for (const k of this.mounted.keys()) {
|
|
146
|
+
if (items.has(k) && prevItems.has(k) && items.get(k) !== prevItems.get(k)) tree().vlistInvalidate(id, k)
|
|
166
147
|
}
|
|
167
|
-
if (this._h.id !== 0 && gone.length > 0) tree().vlistRemoveKeys(this._h.id, gone)
|
|
168
|
-
return this
|
|
169
148
|
}
|
|
170
149
|
|
|
171
150
|
get itemCount(): number { return this.items.size }
|
|
@@ -233,8 +212,8 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
|
|
|
233
212
|
}
|
|
234
213
|
}
|
|
235
214
|
|
|
236
|
-
/** Create a virtualized list from a config (`keyOf` + `render` + `estimatedHeight`
|
|
237
|
-
*
|
|
215
|
+
/** Create a virtualized list from a config (`data` + `keyOf` + `render` + `estimatedHeight`
|
|
216
|
+
* required). Give it a size (`flexGrow: 1` as a screen body). */
|
|
238
217
|
export function UIVirtualizedList<T>(config: VListConfig<T>): UIVirtualizedList<T> {
|
|
239
218
|
return new VListElement<T>(config)
|
|
240
219
|
}
|
package/src/version.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
|
|
5
5
|
// and a host's embedded version must agree on the major for the host to run the bundle; minor
|
|
6
6
|
// and patch never gate anything (features are detected, never versioned).
|
|
7
|
-
export const SDK_VERSION = "2.
|
|
7
|
+
export const SDK_VERSION = "2.2.0"
|
|
8
8
|
|
|
9
9
|
/** The major of a semver string, or null when it is not one. */
|
|
10
10
|
export const sdkMajor = (version: string | null | undefined): number | null => {
|