lecodes-sdk 2.8.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -43,7 +43,7 @@
43
43
  "marcidb-embedded": "^0.18.0",
44
44
  "sharp": "^0.35.2"
45
45
  },
46
- "version": "2.8.0",
46
+ "version": "2.8.1",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -217,6 +217,11 @@ declare global {
217
217
  * listeners are a flag mask, events go back through the runtime's entry points with the node id.
218
218
  * One implementation per platform: renderers/tgfx (desktop, Quest), the Kotlin views (Android),
219
219
  * UIKit (Apple), the null renderer of lecodes-server.
220
+ * The runtime calls it through a QUEUE (runtime/native src/host/hostqueue.h, docs/plans/
221
+ * host-commit-plan.md): a write reaches the host at the next commit — the end of the tick, before
222
+ * a layout, before a read — in the order the runtime made it; a slot with a result commits first
223
+ * and answers at once; an `@immediate` slot commits first and is called at once (its callbacks and
224
+ * borrowed values are never kept).
220
225
  * @host ui HostUI */
221
226
  export interface HostUI {
222
227
  /** Create the host view for a node of `type` ("column", "text", "image", "input", "screen", …).
@@ -259,7 +264,8 @@ export interface HostUI {
259
264
  viewVersion?(name: string): i32
260
265
  /** NativeView channel: invoke `method` on the live instance `viewId`; `args` is the array of the
261
266
  * call's arguments, valid for the call. Settle exactly one of the two callbacks through the
262
- * dispatch bridge — `onComplete` with the result as ONE value, or with none. */
267
+ * dispatch bridge — `onComplete` with the result as ONE value, or with none.
268
+ * @immediate its callbacks and the borrowed `args` cannot wait for a commit */
263
269
  viewCall?(viewId: i32, method: string, args: value, onComplete: (result?: value) => void, onReject: (message: string) => void): void
264
270
  /** A LAYER root is shown: a pass-through root the runtime lays out at the frame, above every
265
271
  * destination (the app layer) or over the presented surface (a scene's / a native view's / a
@@ -272,8 +278,10 @@ export interface HostUI {
272
278
  /** The layer root is hidden (its view stays; releaseNode says when it is gone). */
273
279
  layerClose(node: nodeid): void
274
280
  /** Load a font face for `fontFamily` (weight 100–900, style 0 normal / 2 italic) from `url`; settle
275
- * one of the callbacks through the dispatch bridge. */
281
+ * one of the callbacks through the dispatch bridge.
282
+ * @immediate its callbacks cannot wait for a commit */
276
283
  registerFont(url: string, fontFamily: string, weight: i32, style: i32, onComplete: () => void, onReject: (err: string) => void): void
284
+ /** @immediate not a tree write */
277
285
  showToast(message: string, duration: i32): void
278
286
  /** Is the button node currently pressed (the UIButton.isPressed getter)? */
279
287
  getButtonPressed(node: nodeid): boolean
@@ -284,9 +292,11 @@ export interface HostUI {
284
292
  * it. @out 4 */
285
293
  getBoundingRect?(node: nodeid): Float32Array | null
286
294
  /** A node's touchStart / longPress listener took the gesture (`ev.track`): keep feeding this
287
- * pointer's moves (`claim` = a PanDirection bitmask, `hasMove` = it wants onMove). */
295
+ * pointer's moves (`claim` = a PanDirection bitmask, `hasMove` = it wants onMove).
296
+ * @immediate the answer belongs to the gesture the host is tracking */
288
297
  touchHandler?(pointerId: i32, hasMove: boolean, claim: u8, node: nodeid): void
289
- /** A long press was HANDLED: swallow the click that would follow the release. */
298
+ /** A long press was HANDLED: swallow the click that would follow the release.
299
+ * @immediate the answer belongs to the gesture the host is tracking */
290
300
  longPressHandler?(pointerId: i32, node: nodeid): void
291
301
  /** Virtualized list: apply a scroll position (physical px). `animated` false = anchoring (idempotent,
292
302
  * after the content height update), true = a user scroll command. */
package/src/inject.ts CHANGED
@@ -63,6 +63,8 @@ export { audio, type PlaySoundOptions, type SpatialOptions, type Rolloff, type A
63
63
  export { openFilePicker, files, type FileData } from "./runtime/files"
64
64
  export { share } from "./runtime/share"
65
65
  export { ClickEvent, TouchStartEvent, LongPressEvent, MouseEnterEvent } from "./runtime/touch"
66
+ // Pan, pinch and twist as one gesture over the per-pointer tracks (see src/runtime/gestures.ts).
67
+ export { trackGesture, type Gesture, type GestureHandler, type GestureTarget } from "./runtime/gestures"
66
68
 
67
69
  // ---- 2D engine (creator-2d) ----
68
70
  export { Scene2D } from "./g2/Scene2D"
package/src/plugin.ts CHANGED
@@ -31,7 +31,8 @@ export type { NativeView } from "./ui/NativeView"
31
31
  export type { FetchResponse } from "./runtime/fetch"
32
32
  // The 3D a wrapper builds on (the "ar" plugin's ARScene — docs/plans/ar-plugin-plan.md): the scene it
33
33
  // extends, the node its anchors are, the mesh it draws a plane's grid with, the math of a pose, the
34
- // touch a placement gesture follows, the System its per-frame work rides on (the scene's first frame).
34
+ // touch a placement gesture follows (and `trackGesture`, the pan / pinch / twist over it), the System
35
+ // its per-frame work rides on (the scene's first frame).
35
36
  // Re-exported, never copied, as the UI above.
36
37
  export { Scene, type SceneOptions } from "./gl/Scene"
37
38
  export { System } from "./core/Aspect"
@@ -43,6 +44,7 @@ export { Vec2, Vec3, type Vec3Like } from "./math/vec"
43
44
  export { Quat, type QuatLike } from "./math/quat"
44
45
  export { Mat4 } from "./math/mat4"
45
46
  export type { ClickEvent, TouchStartEvent } from "./runtime/touch"
47
+ export { trackGesture, type Gesture, type GestureHandler, type GestureTarget } from "./runtime/gestures"
46
48
  export type { DismissOptions } from "./ui/presentable"
47
49
  /** The seam the push plugin's wrapper installs its runtime on (src/runtime/pushSeam.ts): what the app's transport
48
50
  * reads the device's address from, registers through, and a push channel's handlers are kept by. */
@@ -12,8 +12,8 @@
12
12
  // the present (a host mounts and sizes the scene's view after it). A host without the plugin is
13
13
  // refused before anything is asked.
14
14
  //
15
- // `root` is a pose anchor like any other (`scene.anchor(pose)`): the wrapper pins it on the first
16
- // horizontal plane the session reports, so the halves know no root.
15
+ // `root` is a pose anchor like any other (`scene.anchor(pose)`), placed by the wrapper — in front of
16
+ // the user on the first floor the session reports (`_placeRoot`) — so the halves know no root.
17
17
 
18
18
  import {
19
19
  Geometry, Mat4, Material, Mesh, Node, Presentable, Quat, Router, Scene, System, Vec3, _ensurePermission, _navEpoch, _toFile,
@@ -63,6 +63,9 @@ export type ARSceneOptions = SceneOptions & {
63
63
  /** "world" (default): planes, hit tests, anchors, image and QR targets. "images": image and QR
64
64
  * targets only — lighter on iOS; no planes, no hit test, no `root`. */
65
65
  mode?: ARMode
66
+ /** How far in front of the user `root` is placed, metres (default 1): along the camera's heading,
67
+ * on the floor. */
68
+ rootDistance?: number
66
69
  }
67
70
 
68
71
  export type AROpenOptions = PresentOptions & Omit<StartOptions, "mode" | "images" | "codes"> & {
@@ -269,6 +272,8 @@ export class ARScene extends Scene {
269
272
  static get isSupported(): boolean { return supports() }
270
273
 
271
274
  readonly mode: ARMode
275
+ /** Metres between the user and `root` when it is placed (`rootDistance` option, default 1). */
276
+ readonly rootDistance: number
272
277
  /** `open()` warm-renders the scene first (default true). */
273
278
  useWarmRender = true
274
279
 
@@ -291,8 +296,9 @@ export class ARScene extends Scene {
291
296
  private _codesWarned = false
292
297
  private _options: AROpenOptions = {}
293
298
  private _root: Anchor | null = null
294
- /** The first horizontal plane's pose: where `root` is pinned. */
295
- private _floor: ARPose | null = null
299
+ /** The floor's height (world Y, metres): the first horizontal plane below the camera this session
300
+ * found. Null until one is; `root` is placed on it. */
301
+ private _floor: number | null = null
296
302
  /** The image targets. `source` is held for the bytes: a fetched response owns its buffer, which the
297
303
  * host frees once the response is collected — the File the session is handed only names it. */
298
304
  private readonly _images: { anchor: ImageAnchor, image: File, width: number, source: FetchResponse | File }[] = []
@@ -310,6 +316,7 @@ export class ARScene extends Scene {
310
316
  constructor(options: ARSceneOptions = {}) {
311
317
  super(options)
312
318
  this.mode = options.mode ?? "world"
319
+ this.rootDistance = options.rootDistance ?? 1
313
320
  }
314
321
 
315
322
  /** The tracking quality: "notAvailable" before a session, "limited" (with `trackingReason`) while
@@ -328,8 +335,12 @@ export class ARScene extends Scene {
328
335
  for (const plane of this._planes.values()) this._drawPlane(plane)
329
336
  }
330
337
 
331
- /** The floor: an anchor pinned on the first horizontal plane the session finds (it fires `track`
332
- * then). Until that it is the world's origin — where the session began. Not in "images" mode. */
338
+ /** The place in front of the user: an anchor `rootDistance` metres ahead of the camera along its
339
+ * heading, on the floor — the first horizontal plane below the camera the session finds; it is
340
+ * pinned there and fires `track` then — turned the way the user looks (its −Z along the heading,
341
+ * so a model's front, +Z, faces the user). Until the floor is found (and on the web, which finds
342
+ * none) it sits the same way at the camera's own height, not pinned. Made on first use; put
343
+ * content under it. `placeRoot()` puts it in front of the user again. Not in "images" mode. */
333
344
  get root(): Anchor {
334
345
  if (this._root) return this._root
335
346
  if (this.mode === "images") throw new Error('ARScene.root: there is no root in the "images" mode')
@@ -337,10 +348,19 @@ export class ARScene extends Scene {
337
348
  root._scene = this
338
349
  this.add(root)
339
350
  this._root = root
340
- if (this._floor) this._pinRoot(this._floor)
351
+ this._placeRoot()
341
352
  return root
342
353
  }
343
354
 
355
+ /** Put `root` in front of the user again — where they look now, `rootDistance` metres ahead, on the
356
+ * floor if one is known (the camera's height otherwise), facing them: a "reset" button, or a
357
+ * placement after the user has moved. Makes `root` when there is none yet. */
358
+ placeRoot(): Anchor {
359
+ if (!this._root) return this.root
360
+ this._placeRoot()
361
+ return this._root
362
+ }
363
+
344
364
  /** An anchor that follows an image (a printed marker, a poster): the session finds it in the camera
345
365
  * image and keeps the anchor on it — a rigid pose in metres (its centre, Y out of the image, the
346
366
  * image in XZ), its size `anchor.extent`. `physicalWidth` = the printed width in metres. Hidden
@@ -679,11 +699,34 @@ export class ARScene extends Scene {
679
699
  this._pin(anchor, pose)
680
700
  }
681
701
 
682
- /** `root` onto the floor (the first horizontal plane of this session). */
683
- private _pinRoot(pose: ARPose): void {
702
+ /** Where `root` goes now: `rootDistance` metres from the camera along its heading (its forward
703
+ * flattened to the floor; looking straight down or up, the way the top of the screen points), at
704
+ * the floor's height when one is known, else the camera's; turned so its −Z runs along the
705
+ * heading — a model's front (+Z) faces the user. */
706
+ private _rootPose(): ARPose {
707
+ const camera = this.camera
708
+ const eye = camera.worldPosition
709
+ const forward = camera.forward
710
+ let heading = new Vec3(forward.x, 0, forward.z)
711
+ if (heading.lengthSq() < 1e-6) {
712
+ // straight down (the usual way to look at a floor) or up: the screen's top points the way
713
+ const up = Vec3.up.rotate(camera.worldMatrix.rotation)
714
+ heading = new Vec3(up.x, 0, up.z)
715
+ if (forward.y > 0) heading = heading.negate()
716
+ if (heading.lengthSq() < 1e-6) heading = new Vec3(0, 0, -1)
717
+ }
718
+ heading = heading.normalize()
719
+ const position = new Vec3(eye.x + heading.x * this.rootDistance, this._floor ?? eye.y, eye.z + heading.z * this.rootDistance)
720
+ return { position, rotation: Quat.lookRotation(heading) }
721
+ }
722
+
723
+ /** `root` in front of the user: moved there now, and pinned there when the floor is known (a
724
+ * session holds a pin; without one `_pinAll` does not re-pin root — it waits for its own floor). */
725
+ private _placeRoot(): void {
684
726
  const root = this._root!
727
+ const pose = this._rootPose()
685
728
  root.worldMatrix = Mat4.compose(pose.position, pose.rotation)
686
- this._pin(root, pose)
729
+ if (this._floor !== null) this._pin(root, pose)
687
730
  }
688
731
 
689
732
  private _pin(anchor: Anchor, pose: ARPoseLike): void {
@@ -741,10 +784,12 @@ export class ARScene extends Scene {
741
784
  plane._polygon = p.polygon
742
785
  plane._setTracked(true)
743
786
  this._drawPlane(plane)
744
- // the floor: the first horizontal plane, where root is pinned
745
- if (added && p.type === "horizontal" && !this._floor) {
746
- this._floor = pose
747
- if (this._root) this._pinRoot(pose)
787
+ // the floor: the first horizontal plane below the camera (a ceiling is "horizontal" too), where
788
+ // root is placed — in front of the user, at the plane's height; the plane's centre and turn are
789
+ // a patch's and say nothing about where the user is
790
+ if (added && p.type === "horizontal" && this._floor === null && pose.position.y < this.camera.worldPosition.y) {
791
+ this._floor = pose.position.y
792
+ if (this._root) this._placeRoot()
748
793
  }
749
794
  this._emit("plane", { plane, change: added ? "added" : "updated", mergedInto: null })
750
795
  }
@@ -0,0 +1,190 @@
1
+ // trackGesture: one recognizer for pan, pinch and twist, built on the per-pointer tracks. Every
2
+ // host already sends a touchstart per finger and the SDK keeps a TrackHandler per pointer id
3
+ // (ui/tree.ts, gestures; g2/touch.ts for 2D scenes): this module only listens for those
4
+ // touchstarts, `ev.track(...)`s each finger with its own handler, and turns the live set of
5
+ // fingers into one reading. No runtime, contract or host part; the AR plugin's `attachControls`
6
+ // (plugins/ar/sdk/controls.ts) is the same thing done by hand.
7
+ //
8
+ // Two target shapes, one adapter (`listen`):
9
+ // - `onTouchStart(cb)` — UIButton, UIScreen, UIWidget. Such a listener cannot be removed:
10
+ // `remove()` leaves the callback in place and it ignores every event from then on.
11
+ // - `addEventListener("touchstart", cb)` / `removeEventListener` — Scene, Scene2D, Node,
12
+ // Node2D. `remove()` takes the listener off.
13
+ //
14
+ // The reading (`Gesture`, a fresh object per callback — safe to keep) is an INCREMENT, like a
15
+ // track's own deltaX / deltaY: every onMove reports the change since the previous onMove (or since
16
+ // the gesture started), so applying it to the current camera / transform is one line and there is
17
+ // no start state to keep. Moves arrive per finger, so increments compose: over any run of moves
18
+ // the deltas sum and the scales multiply to the change of the whole set (rotations sum exactly for
19
+ // two fingers, closely for more). Only positions are read, never a track's deltas (the web's are
20
+ // measured from the touch-down).
21
+ // centerX / centerY the centroid of the live fingers (the event's client coordinates, logical px)
22
+ // deltaX / deltaY the centroid's move since the previous onMove
23
+ // scale the mean finger distance to the centroid over the previous one
24
+ // rotation the mean change of each finger's angle about the centroid, radians;
25
+ // positive = clockwise on screen (y grows downward, so atan2(dy, dx) increasing)
26
+ // pointers the number of live fingers
27
+ // One finger is a gesture too: its moves are pans with scale 1 and rotation 0 (a finger AT the
28
+ // centroid has no distance and no angle — that, not a finger count, is what makes them neutral;
29
+ // three and more fingers run the same formulas). A finger joining or leaving re-bases silently: the
30
+ // set's previous positions are its current ones, so the next onMove shows no jump. onStart (the
31
+ // first finger) and onEnd (the last one lifted: the centroid is its end position, pointers 0) carry
32
+ // deltas 0, scale 1, rotation 0. A cancel of any finger cancels the whole gesture: onCancel once,
33
+ // then the remaining fingers are ignored (and a finger landing meanwhile is not taken) until all
34
+ // have lifted; the next touch starts a new gesture. `claim` (default true) goes to every finger's
35
+ // TrackHandler, so a scrollable above never takes the pan.
36
+
37
+ import type { TouchStartEvent, TrackHandler } from "./touch"
38
+
39
+ /** One reading of a gesture, handed to `onStart` / `onMove` / `onEnd` (a fresh object each time). */
40
+ export interface Gesture {
41
+ /** The centroid of the live fingers, in the event's client coordinates (logical px). */
42
+ centerX: number
43
+ centerY: number
44
+ /** The centroid's move since the previous `onMove` (0 on start / end). */
45
+ deltaX: number
46
+ deltaY: number
47
+ /** Mean finger distance to the centroid over the previous one (1 with one finger, 1 on start / end). */
48
+ scale: number
49
+ /** The mean change of each finger's angle about the centroid since the previous `onMove`, radians,
50
+ * positive = clockwise on screen (0 with one finger, 0 on start / end). */
51
+ rotation: number
52
+ /** The number of live fingers (0 in `onEnd`: the last one lifted). */
53
+ pointers: number
54
+ }
55
+
56
+ /** What `trackGesture` takes: the gesture's callbacks and the claim every finger's track makes. */
57
+ export interface GestureHandler {
58
+ /** Passed to every finger's `ev.track(...)` (default `true`: a scrollable above never takes the pan). */
59
+ claim?: TrackHandler["claim"]
60
+ /** The first finger landed. */
61
+ onStart?(g: Gesture): void
62
+ /** Any finger moved: the change since the previous `onMove`. */
63
+ onMove?(g: Gesture): void
64
+ /** The last finger lifted. */
65
+ onEnd?(g: Gesture): void
66
+ /** The host cancelled one of the fingers: the whole gesture is over. */
67
+ onCancel?(): void
68
+ }
69
+
70
+ /** Anything that hands out touchstarts: a UI element (`onTouchStart`) or a scene / node
71
+ * (`addEventListener("touchstart", …)`). */
72
+ export type GestureTarget =
73
+ | { onTouchStart(callback: (ev: TouchStartEvent<any>) => void): unknown }
74
+ | {
75
+ addEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<any>) => void): void
76
+ removeEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<any>) => void): void
77
+ }
78
+
79
+ type Finger = { id: number, x: number, y: number }
80
+
81
+ // A finger this close to the centroid (logical px) has no angle, and a set this tight has no scale.
82
+ const EPS = 1e-3
83
+
84
+ /** Start listening on `target`; returns the off switch. */
85
+ const listen = (target: GestureTarget, cb: (ev: TouchStartEvent<any>) => void): (() => void) => {
86
+ if ("addEventListener" in target) {
87
+ target.addEventListener("touchstart", cb)
88
+ return () => target.removeEventListener("touchstart", cb)
89
+ }
90
+ target.onTouchStart(cb)
91
+ return () => {}
92
+ }
93
+
94
+ /**
95
+ * Follow pan, pinch and twist on `target` as one gesture: every finger that lands on it is tracked,
96
+ * and `onMove` reports the change of the whole set since the previous move — the centroid's move,
97
+ * the spread's ratio, the twist's angle. One finger is a gesture too (a pan, scale 1, rotation 0).
98
+ *
99
+ * ```ts
100
+ * trackGesture(scene, {
101
+ * onMove(g) { zoom *= g.scale; angle += g.rotation; panX += g.deltaX; panY += g.deltaY },
102
+ * })
103
+ * ```
104
+ */
105
+ export const trackGesture = (target: GestureTarget, handler: GestureHandler): { remove(): void } => {
106
+ const fingers: Finger[] = []
107
+ let cancelled = false // a finger was cancelled: ignore the rest until all have lifted
108
+ let removed = false
109
+
110
+ const drop = (f: Finger): boolean => {
111
+ const i = fingers.indexOf(f)
112
+ if (i < 0) return false
113
+ fingers.splice(i, 1)
114
+ if (fingers.length === 0) cancelled = false
115
+ return true
116
+ }
117
+
118
+ // One finger moved from where it was to (x, y): the set's change, before → after.
119
+ const step = (f: Finger, x: number, y: number): Gesture => {
120
+ const n = fingers.length
121
+ let sx = 0, sy = 0
122
+ for (const p of fingers) { sx += p.x; sy += p.y }
123
+ const c0x = sx / n, c0y = sy / n
124
+ const c1x = (sx - f.x + x) / n, c1y = (sy - f.y + y) / n
125
+ let d0 = 0, d1 = 0, turn = 0, turned = 0
126
+ for (const p of fingers) {
127
+ const ax = p.x - c0x, ay = p.y - c0y
128
+ const bx = (p === f ? x : p.x) - c1x, by = (p === f ? y : p.y) - c1y
129
+ const r0 = Math.hypot(ax, ay), r1 = Math.hypot(bx, by)
130
+ d0 += r0
131
+ d1 += r1
132
+ if (r0 > EPS && r1 > EPS) {
133
+ let a = Math.atan2(by, bx) - Math.atan2(ay, ax)
134
+ if (a > Math.PI) a -= 2 * Math.PI
135
+ else if (a <= -Math.PI) a += 2 * Math.PI
136
+ turn += a
137
+ turned++
138
+ }
139
+ }
140
+ f.x = x
141
+ f.y = y
142
+ return {
143
+ centerX: c1x, centerY: c1y, deltaX: c1x - c0x, deltaY: c1y - c0y,
144
+ scale: d0 > EPS * n ? d1 / d0 : 1, rotation: turned ? turn / turned : 0, pointers: n,
145
+ }
146
+ }
147
+
148
+ const onTouch = (ev: TouchStartEvent<any>): void => {
149
+ if (removed || cancelled) return
150
+ // A pointer id already in the set never got its end (its tracker was replaced): the new touch
151
+ // takes its place in the same gesture.
152
+ const stale = fingers.findIndex((p) => p.id === ev.pointerId)
153
+ if (stale >= 0) fingers.splice(stale, 1)
154
+ const first = fingers.length === 0 && stale < 0
155
+ const f: Finger = { id: ev.pointerId, x: ev.clientX, y: ev.clientY }
156
+ fingers.push(f)
157
+ ev.track({
158
+ claim: handler.claim ?? true,
159
+ // only when the gesture has an onMove: otherwise the host never crosses into JS for the moves
160
+ onMove: handler.onMove ? (pos) => {
161
+ if (removed || cancelled || fingers.indexOf(f) < 0) return
162
+ handler.onMove?.(step(f, pos.clientX, pos.clientY))
163
+ } : undefined,
164
+ onEnd: (pos) => {
165
+ if (removed) return
166
+ const live = !cancelled
167
+ if (!drop(f) || !live || fingers.length > 0) return
168
+ handler.onEnd?.({ centerX: pos.clientX, centerY: pos.clientY, deltaX: 0, deltaY: 0, scale: 1, rotation: 0, pointers: 0 })
169
+ },
170
+ onCancel: () => {
171
+ if (removed || fingers.indexOf(f) < 0) return
172
+ const was = cancelled
173
+ cancelled = true
174
+ drop(f)
175
+ if (!was) handler.onCancel?.()
176
+ },
177
+ })
178
+ if (first) handler.onStart?.({ centerX: f.x, centerY: f.y, deltaX: 0, deltaY: 0, scale: 1, rotation: 0, pointers: 1 })
179
+ }
180
+
181
+ const off = listen(target, onTouch)
182
+ return {
183
+ remove() {
184
+ if (removed) return
185
+ removed = true
186
+ fingers.length = 0
187
+ off()
188
+ },
189
+ }
190
+ }
package/src/ui/UIModal.ts CHANGED
@@ -23,6 +23,14 @@ export type UIModalShow = (() => boolean) | Signal<boolean>
23
23
  const MODAL_ANIM_MS = 200
24
24
  const SHOW_BINDING = "#show"
25
25
 
26
+ // The transition period's `display` on a modal: said once per run, not per write.
27
+ let displayWarned = false
28
+ function warnDisplayDeprecated(): void {
29
+ if (displayWarned) return
30
+ displayWarned = true
31
+ console.warn("deprecated: a modal's `display` — show it with show() / hide(), or bind it with bindShow(() => …) (+ onDismiss for the scrim tap / back); `display` on a modal will throw again")
32
+ }
33
+
26
34
  /**
27
35
  * A dialog: a `UIWidget` with the modal boilerplate built in. Comes with a scrim
28
36
  * (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden), is HIDDEN until shown, animates on
@@ -38,9 +46,10 @@ const SHOW_BINDING = "#show"
38
46
  * request, `onDismiss`. By default it hides an unbound modal and writes `false` into a bound
39
47
  * signal; a bound function has nowhere to write, so a modal bound to one without `onDismiss`
40
48
  * cannot be dismissed (a forced choice — the same as `dismissible(false)`). `display` is not a
41
- * modal's switch: a `.style({ display })` on it throws (a bound display died on the first
42
- * dismissal, which wrote a static `display: "none"` over the binding — the dialog never opened
43
- * again).
49
+ * modal's switch (a bound display died on the first dismissal, which wrote a static
50
+ * `display: "none"` over the binding — the dialog never opened again). Deprecated, for the
51
+ * transition period a `.style({ display })` is still accepted, with a warning: a function becomes
52
+ * `bindShow(() => fn() !== "none")`, a value a show() / hide(); it will throw again.
44
53
  */
45
54
  export interface UIModal extends UIWidget {
46
55
  /**
@@ -107,14 +116,40 @@ export class ModalElement extends WidgetElement {
107
116
  this._syncPose()
108
117
  }
109
118
 
110
- /** @internal `display` is not a modal's switch. Refused on every write of the app's — `.style({
111
- * display })`, `el.style.display =` — and let through for the widget's own (`_ownWrite`). During
112
- * the constructor the field is still undefined (it is initialised after `super()`), which lets
113
- * the default `display: "none"` through. */
119
+ /** @internal `display` is not a modal's switch — DEPRECATED, accepted for the transition period
120
+ * and translated: a function → `bindShow(() => fn() !== "none")`, a value → the start state of
121
+ * an unplaced modal or a show() / hide() of a placed one (replacing a binding, as a static write
122
+ * did). The widget's own writes (`_ownWrite`) pass untouched. During the constructor the field is
123
+ * still undefined (it is initialised after `super()`), which lets the default `display: "none"`
124
+ * through. */
114
125
  _mapStyle(style: any): void {
115
- if ("display" in style && this._ownWrite === false) {
116
- throw new Error("a modal's `display` is not its switch — a dialog, a sheet or a menu is shown with show() / hide(), or bound to the data with bindShow(…), never by its display")
126
+ if (!("display" in style) || this._ownWrite !== false) return
127
+ const value = style.display
128
+ delete style.display
129
+ warnDisplayDeprecated()
130
+ if (typeof value === "function") { this._bindDisplay(value); return }
131
+ if (this._bound) {
132
+ disposeBinding(this, SHOW_BINDING)
133
+ this._bound = false
134
+ this._showSignal = undefined
135
+ this._syncDismissible()
117
136
  }
137
+ const shown = value !== "none"
138
+ if (this._h.id === 0) return
139
+ if (tree().parent(this._h.id) === 0 && !this._shown) this._writeDisplay(shown ? "flex" : "none") // the start state
140
+ else this._setOpen(shown)
141
+ }
142
+
143
+ /** @internal `el.style.display = () => …` (the style proxy's binding form) — DEPRECATED, as `_mapStyle`. */
144
+ _bindStyle(key: string, fn: () => any): boolean {
145
+ if (key !== "display") return false
146
+ warnDisplayDeprecated()
147
+ this._bindDisplay(fn)
148
+ return true
149
+ }
150
+
151
+ private _bindDisplay(fn: () => any): void {
152
+ this.bindShow(() => fn() !== "none")
118
153
  }
119
154
 
120
155
  // The pose the runtime plays on a display flip: the hidden pose + its duration, plus the scrim
package/src/ui/UINode.ts CHANGED
@@ -399,6 +399,7 @@ const handler: ProxyHandler<any> = {
399
399
  // dispose the binding (this is the hot path) — override statically via `.style({ prop })`.
400
400
  const el = target._el
401
401
  const fn = value as unknown as () => any
402
+ if (el._bindStyle?.(key, fn) === true) return true // the element took the binding itself
402
403
  createBinding(el, key, () => { el.style[key] = fn() })
403
404
  return true
404
405
  }
@@ -518,6 +519,9 @@ export class Element<T extends string> implements TreeElement {
518
519
  * `type: "phone"` → `"tel"`), mutating the style in place before it is stored or sent. Runs on
519
520
  * every entry point: the constructor's style, `.style()`, and single-key `el.style.k =` writes. */
520
521
  _mapStyle?(style: any): void
522
+ /** @internal A function written as `el.style.key = () => …`: true when the element bound it
523
+ * itself (a modal's deprecated `display`), false for the ordinary per-key binding. */
524
+ _bindStyle?(key: string, fn: () => any): boolean
521
525
 
522
526
  // Per-type default styles are inlined at each component's construction site (spread UNDER the
523
527
  // user's style — e.g. UIRow's `flexDirection: "row"`), so every node reaches the host with
package/src/ui/tree.ts CHANGED
@@ -233,6 +233,14 @@ export const unregisterNativeView = (viewId: number): void => { nativeViews.dele
233
233
  // ---- gestures -----------------------------------------------------------------------------------------------
234
234
  // The track handler of every pointer a listener claimed (`ev.track(...)`): the runtime feeds the
235
235
  // pointer's moves / end / cancel here by pointer id — nothing of it is retained natively.
236
+ //
237
+ // Several fingers on one node — the rule every host keeps (renderers/uikit + renderers/android
238
+ // TouchPipeline, renderers/tgfx ui_internal.h Gesture, renderers/web-lite events.ts, web-canvas
239
+ // TreeUI): every finger is its own pointer — its own touchStart and so its own `ev.track`, its own
240
+ // moves and end / cancel at its own point, keyed here by its id. The click and the long press are the
241
+ // node's PRIMARY finger's, the one that pressed it while no other held it: a two-finger tap is ONE
242
+ // click, a second finger landing cancels a hold still pending (a pinch is not a hold). `$pressed`
243
+ // holds until the last finger lifts.
236
244
  const trackers = new Map<number, TrackLike>()
237
245
 
238
246
  // The hover's own slot — the cursor is one, and it is NOT a gesture: a mouse drag keeps its touch
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.8.0"
7
+ export const SDK_VERSION = "2.8.1"
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 => {