@solidrt/core 0.0.36 → 0.0.38

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.
@@ -16,6 +16,9 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
16
16
  - `frame-animation.tsx` - `onFrame` driving a transform animation each frame.
17
17
  - `on-layout-connect.tsx` - `onLayout` + `getBoundingBox` connecting laid-out boxes with a `d-path`.
18
18
 
19
+ ## Pointer input
20
+ - `pointer-local-coords.tsx` - the three pointer coordinate frames (`clientX` window, `localX` the handling node's own frame, `parentX` its path-parent's frame - where the node's x/y live) and the transform-proof drag idiom: grab offset from `localX` at down, place with `parentX - offset` on moves. Exact inside rotated/scaled ancestors and when the pointer leaves the node mid-drag.
21
+
19
22
  ## Performance
20
23
  - `repaint-boundary.tsx` - `repaintBoundary` on a `<view>` to keep static content from rebuilding while a neighbor animates: `{true}` retains the recorded draw list, `"snapshot"` also retains the rasterized pixels as a GPU texture (for raster-expensive, screen-aligned, static subtrees).
21
24
 
@@ -0,0 +1,53 @@
1
+ // Pointer events carry the pointer position in three coordinate frames,
2
+ // resolved per node as the event bubbles:
3
+ // - clientX/clientY - the window frame.
4
+ // - localX/localY - the frame of the node whose handler is running, its whole
5
+ // transform chain undone. Exact even when the pointer is not over the node:
6
+ // after a pointer down, moves route along the frozen down path and keep
7
+ // reporting true locals (a fast drag cannot escape the chip below).
8
+ // - parentX/parentY - the frame of the node's path parent, which is the frame
9
+ // the node's own x/y props live in.
10
+ // The drag idiom needs no transform math in the app: take the grab offset from
11
+ // localX/localY at pointer down, place with parentX/parentY - offset during
12
+ // moves. The surface here is rotated and scaled to prove the point - the chip
13
+ // still tracks the pointer exactly. Keying the grab by pointerId keeps
14
+ // concurrent touches (each routed along its own down path) independent.
15
+ import { render, createSignal } from "@solidrt/core"
16
+
17
+ function App() {
18
+ let [pos, setPos] = createSignal({ x: 40, y: 40 })
19
+ let grab: { pointer: number; dx: number; dy: number } | null = null
20
+
21
+ return (
22
+ <window alignItems="center" justifyContent="center">
23
+ <view width={360} height={240} rotate={0.3} scale={1.2}>
24
+ <d-rect radius={16} color="#2a2f3a" />
25
+ <view
26
+ position="absolute"
27
+ width={100}
28
+ height={64}
29
+ x={pos().x}
30
+ y={pos().y}
31
+ justifyContent="center"
32
+ alignItems="center"
33
+ onPointerDown={(e) => {
34
+ if (grab) return
35
+ grab = { pointer: e.pointerId, dx: e.localX, dy: e.localY }
36
+ }}
37
+ onPointerMove={(e) => {
38
+ if (!grab || e.pointerId !== grab.pointer) return
39
+ setPos({ x: e.parentX - grab.dx, y: e.parentY - grab.dy })
40
+ }}
41
+ onPointerUp={() => {
42
+ grab = null
43
+ }}
44
+ >
45
+ <d-rect radius={12} color="#3366b3" />
46
+ <text color="white">drag me</text>
47
+ </view>
48
+ </view>
49
+ </window>
50
+ )
51
+ }
52
+
53
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "colord": "^2.9.3"
28
28
  },
29
29
  "devDependencies": {
30
- "@solidrt/flux-types": "0.0.36"
30
+ "@solidrt/flux-types": "0.0.38"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@solidjs/signals": "2.0.0-beta.20",
@@ -28,6 +28,8 @@ export interface InputDevices {
28
28
 
29
29
  export type SystemTheme = "dark" | "light" | "unknown"
30
30
 
31
+ export type Visibility = "visible" | "hidden"
32
+
31
33
  export type Orientation = "portrait" | "portraitFlipped" | "landscape" | "landscapeFlipped" | "unknown"
32
34
 
33
35
  let devicesAccessor: (() => InputDevices | undefined) | undefined
@@ -52,6 +54,18 @@ function ensureSystemThemeState() {
52
54
  systemThemeAccessor = theme
53
55
  }
54
56
 
57
+ let visibilityAccessor: (() => Visibility) | undefined
58
+
59
+ function ensureVisibilityState() {
60
+ if (visibilityAccessor) return
61
+ let [visibility, setVisibility] = createSignal<Visibility>("visible", { ownedWrite: true })
62
+ // Sticky. The runtime may report the same state through several platform
63
+ // paths; the signal's equality check turns repeats into no-ops for
64
+ // reactive consumers.
65
+ on("visibility", (e: { state?: Visibility }) => setVisibility(e.state === "hidden" ? "hidden" : "visible"))
66
+ visibilityAccessor = visibility
67
+ }
68
+
55
69
  let orientationAccessor: (() => Orientation) | undefined
56
70
 
57
71
  function ensureOrientationState() {
@@ -167,6 +181,21 @@ export let env = {
167
181
  ensureTextScaleState()
168
182
  return textScaleAccessor!()
169
183
  },
184
+ /**
185
+ * Whether the app is on screen: "hidden" while backgrounded (Android) or
186
+ * minimized (desktop), "visible" again on return. The web's
187
+ * `visibilityState` vocabulary without the `document` machinery - react
188
+ * to it in a tracked scope (JSX, memo, effect).
189
+ *
190
+ * This is the persistence moment: there is no close event on any
191
+ * platform (Android gives no time, desktop window close never enters
192
+ * JS), so save state when this goes "hidden". While hidden, timers keep
193
+ * running but no frames are produced.
194
+ */
195
+ get visibility(): Visibility {
196
+ ensureVisibilityState()
197
+ return visibilityAccessor!()
198
+ },
170
199
  /** Orientation of the display the window is on. */
171
200
  get orientation(): Orientation {
172
201
  ensureOrientationState()
package/src/index.ts CHANGED
@@ -3,10 +3,11 @@ export { setFocus, getFocusedNodeId, measureText, getBoundingBox, getBoundingBox
3
3
  export type { BoundingBox } from "./core"
4
4
  export { parseColor, mixColors, brightness, createLinearGradient, createRadialGradient } from "./color"
5
5
  export type { Gradient, GradientStop } from "./color"
6
- export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur } from "./window"
6
+ export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, exit } from "./window"
7
+ export type { BackEvent } from "./window"
7
8
  export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
8
9
  export { env } from "./environment"
9
- export type { InputDevices, SystemTheme, Orientation } from "./environment"
10
+ export type { InputDevices, SystemTheme, Orientation, Visibility } from "./environment"
10
11
  export { gamepads } from "./gamepad"
11
12
  export type { GamepadState } from "./gamepad"
12
13
  export { capabilities } from "./capabilities"
package/src/renderer.ts CHANGED
@@ -97,14 +97,14 @@ function removeNode(parent: ProxyNode, node: ProxyNode): void {
97
97
  // bookkeeping on the hot create/insert paths, orphans are derived from the
98
98
  // proxy map itself: parentless, not the window root, and not awaiting the
99
99
  // destroy sweep. window.ts runs the scan on a rendered frame every few
100
- // seconds; dev bundles only (srt always defines process.env.NODE_ENV, so a
100
+ // seconds; dev bundles only (srt always defines import.meta.env.DEV, so a
101
101
  // production bundle folds the check into a constant early return).
102
102
  const SENTINEL_INTERVAL_MS = 5000
103
103
  let sentinelDue = 0
104
104
  let warnedLeakTypes = new Set<string>()
105
105
 
106
106
  export function scanForOrphans(now: number): void {
107
- if (process.env.NODE_ENV === "production") return
107
+ if (!import.meta.env.DEV) return
108
108
  if (now < sentinelDue) return
109
109
  sentinelDue = now + SENTINEL_INTERVAL_MS
110
110
  let counts = new Map<string, number>()
@@ -23,6 +23,14 @@ declare module "*.jpeg" {
23
23
  const bytes: Uint8Array
24
24
  export default bytes
25
25
  }
26
+ declare module "*.wav" {
27
+ const bytes: Uint8Array
28
+ export default bytes
29
+ }
30
+ declare module "*.ogg" {
31
+ const bytes: Uint8Array
32
+ export default bytes
33
+ }
26
34
 
27
35
  // UI event bus (lattice), provided by the runtime as a builtin module.
28
36
  // on/once return an unsubscribe function.
@@ -31,12 +39,29 @@ declare module "srt:events" {
31
39
  export function once(event: string, callback: (data: any) => void): () => void
32
40
  }
33
41
 
42
+ // The running application's own surface (lattice), present in every build.
43
+ declare module "srt:app" {
44
+ /**
45
+ * Leave the current app, unconditionally: back to the launcher in a dev
46
+ * client, quit when standalone or at the launcher root (on Android the
47
+ * client backgrounds instead of dying). The default action of an
48
+ * unprevented `back` event; prefer the @solidrt/core re-export.
49
+ */
50
+ export function exit(): void
51
+ }
52
+
34
53
  // Dev-server control surface (lattice). Present only in dev/go builds; in other
35
54
  // builds `available` is false and the functions are no-ops.
36
55
  declare module "srt:dev" {
37
56
  export const available: boolean
38
57
  export const canDiscover: boolean
39
58
  export const recents: string[]
59
+ /**
60
+ * The dev-server address the client was launched with (so the launcher can
61
+ * auto-connect without on-device interaction), or null when launched without
62
+ * one.
63
+ */
64
+ export const launchAddress: string | null
40
65
  export function connect(address: string): void
41
66
  export function discover(): void
42
67
  export function stop(): void
@@ -63,8 +88,13 @@ declare module "srt:apps" {
63
88
  export type InstalledApp = { id: string; name: string; version: string }
64
89
  /** Installed apps, sorted by name. */
65
90
  export function list(): InstalledApp[]
66
- /** A stored version: id (manifest hash), bytes on disk, whether it is the current one. */
67
- export type AppVersion = { id: string; size: number; current: boolean }
91
+ /**
92
+ * A stored version: id (manifest hash), bytes on disk, whether it is the
93
+ * current one, and the SolidRT (CLI) release that built it per its
94
+ * manifest ("unknown" from an in-repo CLI or when the manifest predates
95
+ * the field).
96
+ */
97
+ export type AppVersion = { id: string; size: number; current: boolean; solidrtVersion: string }
68
98
  /** One file in a listing: a relative path and its size in bytes. */
69
99
  export type AppFile = { path: string; size: number }
70
100
  /**
package/src/text-input.ts CHANGED
@@ -82,7 +82,7 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
82
82
  }
83
83
 
84
84
  // Ordered selection bounds [start, end).
85
- let range = () => {
85
+ let range = (): [number, number] => {
86
86
  let { anchor, focus } = selection()
87
87
  return anchor <= focus ? [anchor, focus] : [focus, anchor]
88
88
  }
package/src/types.d.ts CHANGED
@@ -9,6 +9,16 @@ import type { Element } from "solid-js"
9
9
  // non-module declaration file, and this file is a module.
10
10
 
11
11
  declare global {
12
+ interface ImportMeta {
13
+ /**
14
+ * Build-mode constants, substituted textually by the srt bundler (Vite
15
+ * vocabulary). `DEV` is true in dev bundles and false in production
16
+ * bundles, where the substituted constant lets the minifier fold
17
+ * dev-only code away entirely.
18
+ */
19
+ readonly env: { readonly DEV: boolean }
20
+ }
21
+
12
22
  let image: {
13
23
  decodeImage(bytes: Uint8Array): { data: Uint8Array, width: number, height: number }
14
24
  }
@@ -180,6 +190,29 @@ export interface TransformProps {
180
190
  export interface PointerEvent {
181
191
  clientX: number
182
192
  clientY: number
193
+ /**
194
+ * Pointer position in the coordinate frame of the node whose handler is
195
+ * running (its transform chain undone), so it differs per node as the event
196
+ * bubbles. Exact even when the pointer is not over the node: a drag routed
197
+ * along the frozen down-path keeps reporting true local coordinates after
198
+ * leaving it.
199
+ */
200
+ localX: number
201
+ localY: number
202
+ /**
203
+ * Pointer position in the frame the running node's own x/y coordinates live
204
+ * in: its parent on the hit path (the window for the root). The drag idiom
205
+ * is `x = parentX - grab offset`, with the grab offset taken from
206
+ * localX/localY at pointer down. The path parent skips
207
+ * pointerEvents="none" ancestors, so it is the layout parent in ordinary
208
+ * trees.
209
+ */
210
+ parentX: number
211
+ parentY: number
212
+ /** Node id whose handler is currently running (bubbling changes it per call). */
213
+ currentTarget: number
214
+ /** Deepest node id of the event's path (the hit leaf). */
215
+ target: number
183
216
  pointerId: number
184
217
  pointerType: "mouse" | "touch" | "pen" | (string & {})
185
218
  button?: number
@@ -198,8 +231,18 @@ export interface WheelEvent extends PointerEvent {
198
231
  deltaY: number
199
232
  }
200
233
 
234
+ // Key events use the W3C UI Events vocabulary: `key` is the logical,
235
+ // layout-dependent value ("a", "!", "Enter", "ArrowLeft"); `code` is the
236
+ // physical, layout-independent key position ("KeyA", "Digit1", "NumpadEnter").
237
+ // Printable characters for text entry arrive via onTextInput, not here.
201
238
  export interface KeyEvent {
202
239
  key: string
240
+ code: string
241
+ repeat: boolean
242
+ shiftKey: boolean
243
+ ctrlKey: boolean
244
+ altKey: boolean
245
+ metaKey: boolean
203
246
  }
204
247
 
205
248
  export interface TextEvent {
@@ -322,6 +365,14 @@ export interface TextProps extends Position, PaintProps, PointerProps {
322
365
 
323
366
  export interface TextureProps extends Position, PointerProps {
324
367
  src?: number
368
+ /**
369
+ * How the texture's pixels map to the element box (CSS object-fit).
370
+ * "fill" (default) stretches; "cover" and "none" crop; "contain" and
371
+ * "scale-down" letterbox. Everything centers - there is no object-position.
372
+ * Paint-only: the element box itself is unaffected, so "contain" letterbox
373
+ * bars and "cover" cropped edges still hit-test as part of the element.
374
+ */
375
+ fit?: "fill" | "cover" | "contain" | "none" | "scale-down"
325
376
  w?: number
326
377
  h?: number
327
378
  srcX?: number
package/src/window.ts CHANGED
@@ -2,20 +2,27 @@ import { createSignal, onCleanup, onSettled, flush } from "@solidjs/signals"
2
2
  import { requestFrame } from "flux:rendertree"
3
3
  import { renderFrame } from "srt:render"
4
4
  import { on, once } from "srt:events"
5
+ import { exit } from "srt:app"
5
6
  import { getEventHandler, getFocusedNodeId, setFocus } from "./core"
6
7
  import { scanForOrphans } from "./renderer"
7
8
 
9
+ /**
10
+ * Leaves the current app, unconditionally: back to the launcher in a dev
11
+ * client, quitting when standalone or at the launcher itself (on Android the
12
+ * client backgrounds instead of dying). The default action of an unprevented
13
+ * `back` event; call it directly to exit programmatically, e.g. after
14
+ * intercepting back for an unsaved-changes dialog.
15
+ */
16
+ export { exit }
17
+
8
18
  // ------ Pointer routing -----------------
9
19
 
10
- // Hit path frozen at pointerDown, pointerId -> targets. While a pointer has an
11
- // active down, its moves and up dispatch along this path (same leaf-to-root
12
- // bubble) no matter where the pointer currently is, so every node under the
13
- // original down observes the whole gesture: a drag keeps working off-element,
14
- // and an ancestor recognizer (a scroller's pan) sees the moves it needs to
15
- // take over mid-gesture via the arena. There is no exclusive pointer capture;
16
- // gesture ownership is claim-based, above this layer. Enter/leave stay
17
- // hover-driven, and moves with no active down follow the live hit path.
18
- let downPaths = new Map<number, number[]>()
20
+ // Routing lives in the engine: the runtime freezes each pointer's hit path at
21
+ // pointerDown and delivers every event with its exact targets plus per-node
22
+ // local/parent-frame coordinate arrays (see PointerEvent in types.d.ts). This
23
+ // side only walks the delivered path, resolving the per-node scalars before
24
+ // each handler. There is no exclusive pointer capture; gesture ownership is
25
+ // claim-based, above this layer.
19
26
 
20
27
  // ------ Animation frames ----------------
21
28
 
@@ -179,6 +186,30 @@ export function onWindowBlur(fn: () => void) {
179
186
  return unsubscribe
180
187
  }
181
188
 
189
+ // ------ Back ----------------
190
+
191
+ export type BackEvent = { preventDefault: () => void }
192
+
193
+ // App handlers for the window-level back event, run in registration order.
194
+ // Kept in a local registry (not per-handler bus subscriptions) so the default
195
+ // action runs exactly once, after every handler has had its say.
196
+ let backHandlers = new Set<(e: BackEvent) => void>()
197
+
198
+ /**
199
+ * Calls `fn` on the user's back intent (Android back button/gesture, the
200
+ * desktop dev chord). Call `e.preventDefault()` when back means in-app
201
+ * navigation right now (close a modal, previous screen); unprevented, the
202
+ * default action runs: exit(). Apps without a handler exit on back
203
+ * everywhere, which is the correct zero-effort default.
204
+ * Returns a cleanup function; also auto-cleans within a reactive scope.
205
+ */
206
+ export function onBack(fn: (e: BackEvent) => void) {
207
+ backHandlers.add(fn)
208
+ let cleanup = () => backHandlers.delete(fn)
209
+ onCleanup(cleanup)
210
+ return cleanup
211
+ }
212
+
182
213
  // ------ Window ----------------
183
214
 
184
215
  export function attachWindow(_nodeId: number) {
@@ -191,6 +222,7 @@ export function attachWindow(_nodeId: number) {
191
222
  let unsubWheel: () => void = null!
192
223
  let unsubKeyDown: () => void = null!
193
224
  let unsubKeyUp: () => void = null!
225
+ let unsubBack: () => void = null!
194
226
  let unsubTextInput: () => void = null!
195
227
  let unsubKeyboardVisibility: () => void = null!
196
228
  let unsubRefreshRate: () => void = null!
@@ -217,77 +249,71 @@ export function attachWindow(_nodeId: number) {
217
249
  runFrame(time * 1000, frame)
218
250
  })
219
251
 
220
- // Dispatch an event to every node on the hit path, leaf->root (bubbling), so
221
- // a child handler can call e.stopPropagation() to keep the event from
222
- // reaching its ancestors. `targets` arrives root->leaf, hence the reverse.
223
- let bubble = (targets: number[], handler: string, e: any) => {
252
+ // Dispatch an event to every node on the delivered path, resolving the
253
+ // per-node fields from the parallel wire arrays before each handler:
254
+ // localX/localY is the pointer in that node's own frame, parentX/parentY
255
+ // in its path-parent's frame (the frame the node's x/y live in), and
256
+ // currentTarget the node whose handler is running. `reverse` walks the
257
+ // root->leaf array leaf-first (bubbling), so a child handler can call
258
+ // e.stopPropagation() to keep the event from reaching its ancestors;
259
+ // enter/leave arrive pre-ordered and walk forward.
260
+ interface RawPointer {
261
+ targets: number[]
262
+ localX: number[]
263
+ localY: number[]
264
+ parentX: number[]
265
+ parentY: number[]
266
+ [k: string]: any
267
+ }
268
+ let dispatchPath = (raw: RawPointer, handler: string, reverse: boolean) => {
269
+ let { targets, localX, localY, parentX, parentY, ...e } = raw
224
270
  let stopped = false
225
271
  e.stopPropagation = () => {
226
272
  stopped = true
227
273
  }
228
- for (let i = targets.length - 1; i >= 0; i--) {
274
+ let n = targets.length
275
+ for (let k = 0; k < n; k++) {
276
+ let i = reverse ? n - 1 - k : k
277
+ e.currentTarget = targets[i]!
278
+ e.localX = localX[i]!
279
+ e.localY = localY[i]!
280
+ e.parentX = parentX[i]!
281
+ e.parentY = parentY[i]!
229
282
  getEventHandler(targets[i]!, handler)?.(e)
230
283
  if (stopped) break
231
284
  }
232
285
  }
286
+ let bubble = (raw: RawPointer, handler: string) => dispatchPath(raw, handler, true)
287
+ let dispatchOrdered = (raw: RawPointer, handler: string) => dispatchPath(raw, handler, false)
288
+
289
+ unsubDown = on("pointerDown", (raw: RawPointer) => {
290
+ bubble(raw, "onPointerDown")
291
+ // Outside-tap blur. Read focus AFTER per-node handlers so a tap that
292
+ // moves focus to a new node is not immediately blurred again.
293
+ let focused = getFocusedNodeId()
294
+ if (focused != null && !raw.targets.includes(focused)) {
295
+ setFocus(null)
296
+ }
297
+ })
233
298
 
234
- unsubDown = on(
235
- "pointerDown",
236
- ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
237
- downPaths.set(e.pointerId, targets)
238
- bubble(targets, "onPointerDown", e)
239
- // Outside-tap blur. Read focus AFTER per-node handlers so a tap that
240
- // moves focus to a new node is not immediately blurred again.
241
- let focused = getFocusedNodeId()
242
- if (focused != null && !targets.includes(focused)) {
243
- setFocus(null)
244
- }
245
- },
246
- )
247
-
248
- unsubUp = on("pointerUp", ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
249
- let frozen = downPaths.get(e.pointerId)
250
- downPaths.delete(e.pointerId)
251
- bubble(frozen ?? targets, "onPointerUp", e)
299
+ unsubUp = on("pointerUp", (raw: RawPointer) => {
300
+ bubble(raw, "onPointerUp")
252
301
  })
253
302
 
254
- unsubMove = on(
255
- "pointerMove",
256
- ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
257
- bubble(downPaths.get(e.pointerId) ?? targets, "onPointerMove", e)
258
- },
259
- )
260
-
261
- // Enter/leave keep their hover-diff order (already leaf->root for leave,
262
- // root->leaf for enter), but still honor stopPropagation for a consistent
263
- // event shape.
264
- let dispatchOrdered = (targets: number[], handler: string, e: any) => {
265
- let stopped = false
266
- e.stopPropagation = () => {
267
- stopped = true
268
- }
269
- for (let nodeId of targets) {
270
- getEventHandler(nodeId, handler)?.(e)
271
- if (stopped) break
272
- }
273
- }
303
+ unsubMove = on("pointerMove", (raw: RawPointer) => {
304
+ bubble(raw, "onPointerMove")
305
+ })
274
306
 
275
- unsubEnter = on(
276
- "pointerEnter",
277
- ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
278
- dispatchOrdered(targets, "onPointerEnter", e)
279
- },
280
- )
281
-
282
- unsubLeave = on(
283
- "pointerLeave",
284
- ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
285
- dispatchOrdered(targets, "onPointerLeave", e)
286
- },
287
- )
288
-
289
- unsubWheel = on("wheel", ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
290
- bubble(targets, "onWheel", e)
307
+ unsubEnter = on("pointerEnter", (raw: RawPointer) => {
308
+ dispatchOrdered(raw, "onPointerEnter")
309
+ })
310
+
311
+ unsubLeave = on("pointerLeave", (raw: RawPointer) => {
312
+ dispatchOrdered(raw, "onPointerLeave")
313
+ })
314
+
315
+ unsubWheel = on("wheel", (raw: RawPointer) => {
316
+ bubble(raw, "onWheel")
291
317
  })
292
318
 
293
319
  unsubKeyDown = on("keydown", (e: any) => {
@@ -304,6 +330,18 @@ export function attachWindow(_nodeId: number) {
304
330
  }
305
331
  })
306
332
 
333
+ unsubBack = on("back", () => {
334
+ let prevented = false
335
+ let e: BackEvent = {
336
+ preventDefault: () => {
337
+ prevented = true
338
+ },
339
+ }
340
+ // Copy first: a handler may unregister (itself or others) mid-dispatch.
341
+ for (let fn of [...backHandlers]) fn(e)
342
+ if (!prevented) exit()
343
+ })
344
+
307
345
  unsubTextInput = on("textInput", (e: any) => {
308
346
  let id = getFocusedNodeId()
309
347
  if (id != null) {
@@ -339,6 +377,7 @@ export function attachWindow(_nodeId: number) {
339
377
  if (unsubWheel) unsubWheel()
340
378
  if (unsubKeyDown) unsubKeyDown()
341
379
  if (unsubKeyUp) unsubKeyUp()
380
+ if (unsubBack) unsubBack()
342
381
  if (unsubTextInput) unsubTextInput()
343
382
  if (unsubKeyboardVisibility) unsubKeyboardVisibility()
344
383
  if (unsubRefreshRate) unsubRefreshRate()