@solidrt/core 0.0.46 → 0.0.47
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/AGENTS.md +16 -0
- package/package.json +2 -2
- package/src/arena.ts +60 -0
- package/src/color.ts +5 -2
- package/src/gpu.ts +15 -17
- package/src/index.ts +7 -0
- package/src/pan.ts +105 -0
- package/src/renderer.ts +4 -1
- package/src/runtime-modules.d.ts +5 -1
- package/src/transform.ts +300 -0
- package/src/types.d.ts +12 -8
- package/src/window.ts +35 -7
package/AGENTS.md
CHANGED
|
@@ -174,6 +174,22 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
|
174
174
|
app-global shortcut point; `stopPropagation()` ends the walk. `focusable`
|
|
175
175
|
declares focus-navigation candidacy (enumerate via getFocusables()).
|
|
176
176
|
|
|
177
|
+
- Gesture recognizers, shared by every package: `createPan` (single-pointer
|
|
178
|
+
drag, axis-aware slop, per-event dx/dy) and `createTransform` (merged
|
|
179
|
+
pan + pinch + rotate over the whole pointer set, Flutter-Scale style: streams
|
|
180
|
+
`{ dx, dy, scale, rotation, x, y, pointers }` once per FRAME - positions
|
|
181
|
+
update per event, but the cross-pointer measure waits for the
|
|
182
|
+
`pointerFrame` batch terminator, when every pointer is the same age; one
|
|
183
|
+
finger degrades to a plain pan, and `pointers` is how a consumer gives one-
|
|
184
|
+
and two-finger translation different meanings - dx/dy alone cannot tell
|
|
185
|
+
them apart).
|
|
186
|
+
Spread the returned `.handlers` onto the receiving element. They
|
|
187
|
+
arbitrate through the exported `arena` (ONE per app): a press claims its
|
|
188
|
+
pointer provisionally, movement evidence steals and resolves it, the loser's
|
|
189
|
+
`cancel()` retracts its feedback. Custom recognizers should join the arena
|
|
190
|
+
rather than track pointers ad hoc, or they will double-handle against
|
|
191
|
+
scrollers and pressables.
|
|
192
|
+
|
|
177
193
|
- Reactivity is SolidJS 2.0 (`@solidjs/signals`), NOT Solid 1.x. `createSignal`
|
|
178
194
|
is as you expect, but `createEffect` takes the 2.0 two-function shape: a
|
|
179
195
|
TRACKED compute that reads signals and returns a value, then an UNTRACKED
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.47",
|
|
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.
|
|
30
|
+
"@solidrt/flux-types": "0.0.47"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@solidjs/signals": "2.0.0-beta.31",
|
package/src/arena.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The gesture arena: per-pointer ownership arbitration between recognizers.
|
|
2
|
+
// Raw pointer events keep bubbling along the frozen down path regardless; the
|
|
3
|
+
// arena only decides which recognizer's gesture a pointer belongs to. It lives
|
|
4
|
+
// in core beside that routing (window.ts) because arbitration only works with
|
|
5
|
+
// exactly ONE claims map per app: recognizers from every package (components'
|
|
6
|
+
// press and pan, 3d's orbit transform, future runtime-level recognizers) must
|
|
7
|
+
// see each other's claims, and two arenas cannot arbitrate against each other.
|
|
8
|
+
//
|
|
9
|
+
// Two claim strengths mirror how gestures resolve. A press claims its pointer
|
|
10
|
+
// provisionally on the down: it is the presumed winner (innermost-wins falls
|
|
11
|
+
// out of leaf-to-root dispatch, first claim sticks) but a later recognizer
|
|
12
|
+
// with positive evidence of a different gesture (a pan crossing its movement
|
|
13
|
+
// slop) may steal the pointer, cancelling the press. A steal resolves the
|
|
14
|
+
// arena: the pointer is won outright and cannot be stolen again, so e.g. the
|
|
15
|
+
// outer axis of two nested scrollers cannot take a drag the inner one already
|
|
16
|
+
// owns. Plain state on purpose: claims must be visible across recognizers
|
|
17
|
+
// within one synchronous bubble dispatch, before any signal flush.
|
|
18
|
+
|
|
19
|
+
export type ArenaOwner = {
|
|
20
|
+
/** Retract the gesture without firing; invoked when the pointer is stolen. */
|
|
21
|
+
cancel(): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Claim = { owner: ArenaOwner; resolved: boolean }
|
|
25
|
+
|
|
26
|
+
let claims = new Map<number, Claim>()
|
|
27
|
+
|
|
28
|
+
export let arena = {
|
|
29
|
+
/**
|
|
30
|
+
* Provisionally claim an unowned pointer. Returns false (claim refused) when
|
|
31
|
+
* any recognizer already owns it. The claim is stealable until the owner
|
|
32
|
+
* releases it or a steal resolves the arena.
|
|
33
|
+
*/
|
|
34
|
+
claim(pointerId: number, owner: ArenaOwner): boolean {
|
|
35
|
+
if (claims.has(pointerId)) return false
|
|
36
|
+
claims.set(pointerId, { owner, resolved: false })
|
|
37
|
+
return true
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Take the pointer on positive evidence of a gesture, cancelling the current
|
|
42
|
+
* provisional owner (if any), and resolve the arena: the resulting claim
|
|
43
|
+
* cannot be stolen. Returns false when the arena is already resolved, in
|
|
44
|
+
* which case the caller lost and must stand down.
|
|
45
|
+
*/
|
|
46
|
+
steal(pointerId: number, owner: ArenaOwner): boolean {
|
|
47
|
+
let current = claims.get(pointerId)
|
|
48
|
+
if (current) {
|
|
49
|
+
if (current.resolved) return false
|
|
50
|
+
current.owner.cancel()
|
|
51
|
+
}
|
|
52
|
+
claims.set(pointerId, { owner, resolved: true })
|
|
53
|
+
return true
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/** Release the claim on a pointer, if `owner` still holds it. */
|
|
57
|
+
release(pointerId: number, owner: ArenaOwner): void {
|
|
58
|
+
if (claims.get(pointerId)?.owner === owner) claims.delete(pointerId)
|
|
59
|
+
},
|
|
60
|
+
}
|
package/src/color.ts
CHANGED
|
@@ -7,10 +7,13 @@ extend([namesPlugin, mixPlugin])
|
|
|
7
7
|
* Parses a CSS color string (named, hex, `rgb()`, `hsl()`, ...) into a packed
|
|
8
8
|
* `0xRRGGBBAA` u32: red in the high byte, alpha in the low byte. Alpha is scaled
|
|
9
9
|
* from colord's 0..1 to 0..255. This is the wire format the runtime expects for
|
|
10
|
-
* the `color` property.
|
|
10
|
+
* the `color` property. Throws on a string that is not a valid CSS color, so a
|
|
11
|
+
* typo fails on the line that wrote it instead of silently painting black.
|
|
11
12
|
*/
|
|
12
13
|
export function parseColor(color: string): number {
|
|
13
|
-
let
|
|
14
|
+
let c = colord(color)
|
|
15
|
+
if (!c.isValid()) throw new Error(`Invalid color "${color}"`)
|
|
16
|
+
let { r, g, b, a } = c.toRgb()
|
|
14
17
|
return (((r & 0xFF) << 24) | ((g & 0xFF) << 16) | ((b & 0xFF) << 8) | ((a * 255) & 0xFF)) >>> 0
|
|
15
18
|
}
|
|
16
19
|
|
package/src/gpu.ts
CHANGED
|
@@ -178,24 +178,22 @@ export {
|
|
|
178
178
|
*/
|
|
179
179
|
export let glsl = String.raw
|
|
180
180
|
|
|
181
|
-
// captureSnapshot renders a node to
|
|
182
|
-
// texture's bytes back
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
// destroyTexture (as with any texture created after an await).
|
|
181
|
+
// captureSnapshot renders a node to pixels and readTexture reads any
|
|
182
|
+
// texture's bytes back; both resolve the same { width, height, data } shape.
|
|
183
|
+
// A laid-out node captures its layout box; a `d-*` node captures its painted
|
|
184
|
+
// box - its own w/h when set, else the nearest laid-out ancestor's box, its
|
|
185
|
+
// x/y offset mapped to the texture origin. A capture creates no texture and
|
|
186
|
+
// nothing needs freeing; to display or sample the result, upload it with
|
|
187
|
+
// createTexture.
|
|
189
188
|
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
// paint pass
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// pipeline's target, a camera texture).
|
|
189
|
+
// This is the one-shot bake path: draw something only the engine can produce
|
|
190
|
+
// (shaped text, an SVG, a themed view), capture it and process the pixels on
|
|
191
|
+
// the CPU - baking a glyph atlas is the worked example. Not a rendering path:
|
|
192
|
+
// a capture rasterizes the subtree offscreen and reads it back to the CPU,
|
|
193
|
+
// costing a readback stall and a paint pass of latency every call. Batch
|
|
194
|
+
// captures (one paint pass services many), never run them per frame, and do
|
|
195
|
+
// not use them to feed live screen content into a shader - for that the
|
|
196
|
+
// source has to update in place (another pipeline's target, a camera texture).
|
|
199
197
|
export { captureSnapshot, readTexture } from "flux:gpu"
|
|
200
198
|
|
|
201
199
|
/**
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,14 @@ export { parseSvg, svg } from "./svg"
|
|
|
20
20
|
export type { SvgDocument, SvgDraw } from "./svg"
|
|
21
21
|
export { createScroll } from "./scroll"
|
|
22
22
|
export type { Scroll, ScrollAxis, ScrollOffset, ScrollOptions } from "./scroll"
|
|
23
|
+
export { arena } from "./arena"
|
|
24
|
+
export type { ArenaOwner } from "./arena"
|
|
25
|
+
export { createPan } from "./pan"
|
|
26
|
+
export type { PanAxis, PanOptions } from "./pan"
|
|
27
|
+
export { createTransform } from "./transform"
|
|
28
|
+
export type { TransformDelta, TransformOptions } from "./transform"
|
|
23
29
|
export type {
|
|
30
|
+
Element,
|
|
24
31
|
LayoutProps,
|
|
25
32
|
TransformProps,
|
|
26
33
|
PointerProps,
|
package/src/pan.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { onSettled } from "@solidjs/signals"
|
|
2
|
+
import type { PointerEvent } from "./types"
|
|
3
|
+
import { arena } from "./arena"
|
|
4
|
+
|
|
5
|
+
// Movement in logical pixels before a pan activates. Below this a drag still
|
|
6
|
+
// reads as a press (tap wiggle); crossing it is the positive evidence that the
|
|
7
|
+
// gesture is a pan, at which point the pan steals the pointer in the arena.
|
|
8
|
+
const PAN_SLOP = 8
|
|
9
|
+
|
|
10
|
+
export type PanAxis = "vertical" | "horizontal" | "both"
|
|
11
|
+
|
|
12
|
+
export interface PanOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Which movement direction activates the pan. Slop is axis-aware: a
|
|
15
|
+
* "vertical" pan only activates once vertical travel crosses the threshold,
|
|
16
|
+
* so nested cross-axis scrollers each take only drags along their own axis.
|
|
17
|
+
* Default "both" (straight-line distance).
|
|
18
|
+
*/
|
|
19
|
+
axis?: PanAxis
|
|
20
|
+
onPanStart?: () => void
|
|
21
|
+
/** Pointer movement since the previous event; positive dx is rightward, positive dy downward. */
|
|
22
|
+
onPanMove?: (dx: number, dy: number) => void
|
|
23
|
+
onPanEnd?: () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The pan recognizer: turns a drag into a movement-delta stream. On a down it
|
|
27
|
+
// arms and starts measuring; when travel from the down point crosses the slop
|
|
28
|
+
// along the enabled axis it activates, stealing the pointer in the arena (a
|
|
29
|
+
// press that provisionally owned it is cancelled - its feedback retracts) and
|
|
30
|
+
// resolving it so no other recognizer can take the drag over. If the arena is
|
|
31
|
+
// already resolved (an inner pan won first) the recognizer disarms and stays
|
|
32
|
+
// out. The slop distance itself is swallowed: deltas stream from the
|
|
33
|
+
// activation point on. Moves and the up arrive on the frozen down path, so an
|
|
34
|
+
// active pan keeps streaming when the pointer leaves the node or the window.
|
|
35
|
+
// cancel() is the external-cancel hook; it ends an active pan without
|
|
36
|
+
// onPanEnd. Options are read at event time. Single-pointer by design; for
|
|
37
|
+
// multi-pointer pinch/rotate compose createTransform instead.
|
|
38
|
+
export function createPan(options: PanOptions) {
|
|
39
|
+
// Down position while armed; last delivered position while active.
|
|
40
|
+
let origin: { x: number; y: number } | null = null
|
|
41
|
+
let active: number | null = null
|
|
42
|
+
let armed: number | null = null
|
|
43
|
+
|
|
44
|
+
let past = (e: PointerEvent) => {
|
|
45
|
+
if (!origin) return false
|
|
46
|
+
let dx = Math.abs(e.clientX - origin.x)
|
|
47
|
+
let dy = Math.abs(e.clientY - origin.y)
|
|
48
|
+
let axis = options.axis ?? "both"
|
|
49
|
+
if (axis === "vertical") return dy >= PAN_SLOP
|
|
50
|
+
if (axis === "horizontal") return dx >= PAN_SLOP
|
|
51
|
+
return dx * dx + dy * dy >= PAN_SLOP * PAN_SLOP
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let reset = () => {
|
|
55
|
+
if (active != null) {
|
|
56
|
+
arena.release(active, owner)
|
|
57
|
+
active = null
|
|
58
|
+
}
|
|
59
|
+
armed = null
|
|
60
|
+
origin = null
|
|
61
|
+
}
|
|
62
|
+
let cancel = reset
|
|
63
|
+
let owner = { cancel }
|
|
64
|
+
|
|
65
|
+
// An unmount mid-drag must not leave a resolved claim behind.
|
|
66
|
+
onSettled(() => reset)
|
|
67
|
+
|
|
68
|
+
let handlers = {
|
|
69
|
+
onPointerDown: (e: PointerEvent) => {
|
|
70
|
+
if (e.button != null && e.button !== 0) return
|
|
71
|
+
if (armed == null && active == null) {
|
|
72
|
+
armed = e.pointerId
|
|
73
|
+
origin = { x: e.clientX, y: e.clientY }
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
onPointerMove: (e: PointerEvent) => {
|
|
77
|
+
if (armed === e.pointerId && past(e)) {
|
|
78
|
+
if (arena.steal(e.pointerId, owner)) {
|
|
79
|
+
active = e.pointerId
|
|
80
|
+
armed = null
|
|
81
|
+
origin = { x: e.clientX, y: e.clientY }
|
|
82
|
+
options.onPanStart?.()
|
|
83
|
+
} else {
|
|
84
|
+
// The arena is resolved against us; the drag belongs elsewhere.
|
|
85
|
+
reset()
|
|
86
|
+
}
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
if (active === e.pointerId && origin) {
|
|
90
|
+
options.onPanMove?.(e.clientX - origin.x, e.clientY - origin.y)
|
|
91
|
+
origin = { x: e.clientX, y: e.clientY }
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
onPointerUp: (e: PointerEvent) => {
|
|
95
|
+
if (active === e.pointerId) {
|
|
96
|
+
reset()
|
|
97
|
+
options.onPanEnd?.()
|
|
98
|
+
} else if (armed === e.pointerId) {
|
|
99
|
+
reset()
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { handlers, cancel }
|
|
105
|
+
}
|
package/src/renderer.ts
CHANGED
|
@@ -148,8 +148,11 @@ function setTreeProperty(node: ProxyNode, name: string, value: unknown): void {
|
|
|
148
148
|
try {
|
|
149
149
|
tree.setProperty(node.id, name, value)
|
|
150
150
|
} catch (e) {
|
|
151
|
+
// Name-level rejections (the exact prefixes apply_jsx in flux emits) are
|
|
152
|
+
// warn-and-continue so a stale prop does not kill the app; a bad VALUE for
|
|
153
|
+
// a known property rethrows, per the throw-in-dev validation policy.
|
|
151
154
|
let message = String(e)
|
|
152
|
-
if (!message.includes("
|
|
155
|
+
if (!message.includes("Unknown property") && !message.includes("Detached-only")) throw e
|
|
153
156
|
let key = node.elementType + "." + name
|
|
154
157
|
if (warnedRejectedProps.has(key)) return
|
|
155
158
|
warnedRejectedProps.add(key)
|
package/src/runtime-modules.d.ts
CHANGED
|
@@ -49,7 +49,11 @@ declare module "*.ogg" {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
// UI event bus (lattice), provided by the runtime as a builtin module.
|
|
52
|
-
// on/once return an unsubscribe function.
|
|
52
|
+
// on/once return an unsubscribe function. Notable events: the routed pointer
|
|
53
|
+
// stream ("pointerMove"/"pointerDown"/... consumed by window.ts),
|
|
54
|
+
// "pointerFrame" (the move-batch terminator: fires after all of a frame's
|
|
55
|
+
// pointer moves have dispatched, every pointer the same age - multi-pointer
|
|
56
|
+
// recognizers measure there), and "render" (the per-frame signal).
|
|
53
57
|
declare module "srt:events" {
|
|
54
58
|
export function on(event: string, callback: (data: any) => void): () => void
|
|
55
59
|
export function once(event: string, callback: (data: any) => void): () => void
|
package/src/transform.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { onSettled } from "@solidjs/signals"
|
|
2
|
+
import { on } from "srt:events"
|
|
3
|
+
import type { PointerEvent } from "./types"
|
|
4
|
+
import { arena, type ArenaOwner } from "./arena"
|
|
5
|
+
|
|
6
|
+
// Focal travel or span change in logical pixels before the transform
|
|
7
|
+
// activates; the same threshold createPan uses, so the two race fairly.
|
|
8
|
+
const SLOP = 8
|
|
9
|
+
// Per-frame EMA weight for the span that feeds `scale` (~50ms time constant
|
|
10
|
+
// at a 65Hz frame rate - see the zoom-noise note below). The pre-batching
|
|
11
|
+
// weight was 0.15 stepped per event at ~130Hz combined; steps now come once
|
|
12
|
+
// per frame, so the weight compounds: 1 - (1 - 0.15)^2.
|
|
13
|
+
const SPAN_SMOOTH = 0.28
|
|
14
|
+
// Pinch-vs-hold discrimination on the span RATE, an EMA (QUIET_SMOOTH
|
|
15
|
+
// weight) of the smoothed span's per-frame change. Engaging needs both a
|
|
16
|
+
// SLOP excursion and rate >= ENGAGE (~40px/s at 65Hz); an engaged pinch
|
|
17
|
+
// re-locks when rate falls under QUIET (~20px/s). Creep sits near 0.14
|
|
18
|
+
// px/frame, deliberate pinches at 1.5+ - the 2x gap between the two
|
|
19
|
+
// thresholds is the hysteresis that keeps the gate from chattering.
|
|
20
|
+
// All three step-based constants assume a ~65Hz frame rate (the captured
|
|
21
|
+
// tablet); a 120Hz panel halves per-step deltas. If that ever bites,
|
|
22
|
+
// convert the thresholds to px/s with performance.now() read at the
|
|
23
|
+
// pointerFrame terminator.
|
|
24
|
+
const QUIET = 0.3
|
|
25
|
+
const ENGAGE = 0.6
|
|
26
|
+
const QUIET_SMOOTH = 0.19
|
|
27
|
+
|
|
28
|
+
export type TransformDelta = {
|
|
29
|
+
/** Focal-point movement since the previous frame, logical px. */
|
|
30
|
+
dx: number
|
|
31
|
+
dy: number
|
|
32
|
+
/** Multiplicative span change since the previous frame (1 = unchanged, >1 = fingers spreading). */
|
|
33
|
+
scale: number
|
|
34
|
+
/** Rotation of the pointer pair since the previous frame, radians (0 with a single pointer). */
|
|
35
|
+
rotation: number
|
|
36
|
+
/** Current focal point in window coordinates (the clientX/clientY frame) - the zoom-about anchor. */
|
|
37
|
+
x: number
|
|
38
|
+
y: number
|
|
39
|
+
/** How many pointers are down right now. Consumers that give one- and
|
|
40
|
+
* two-finger translation different meanings (rotate vs pan) route on this;
|
|
41
|
+
* dx/dy alone cannot tell them apart, both are focal movement. */
|
|
42
|
+
pointers: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TransformOptions {
|
|
46
|
+
onTransformStart?: () => void
|
|
47
|
+
/** Streams one delta per frame; compose them multiplicatively (scale) / additively (dx, dy, rotation). */
|
|
48
|
+
onTransformMove?: (t: TransformDelta) => void
|
|
49
|
+
onTransformEnd?: () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The merged transform recognizer: pan + pinch + rotate as ONE gesture over
|
|
53
|
+
// the set of pointers down on the node (Flutter's Scale model). Splitting
|
|
54
|
+
// them into separate recognizers would make them fight in the arena over the
|
|
55
|
+
// same fingers, so the genuinely-simultaneous case (drag while pinching a
|
|
56
|
+
// photo, one-finger orbit vs two-finger zoom) is a single recognizer that
|
|
57
|
+
// streams focal-point translation, span scale, and pair rotation together;
|
|
58
|
+
// consumers use the components they care about (an orbit camera reads dx/dy
|
|
59
|
+
// and scale, ignores rotation).
|
|
60
|
+
//
|
|
61
|
+
// Pointers arm silently on their down (no arena claim); when focal travel or
|
|
62
|
+
// span change from the armed configuration crosses the slop, that is positive
|
|
63
|
+
// evidence and the recognizer steals EVERY tracked pointer, all or nothing:
|
|
64
|
+
// if any is already resolved elsewhere (an inner pan won that finger) the
|
|
65
|
+
// whole gesture stands down. The slop is swallowed - deltas stream from the
|
|
66
|
+
// activation configuration on. With one finger it degrades to a plain pan
|
|
67
|
+
// (scale 1, rotation 0), so a drag on the node gets arena arbitration too.
|
|
68
|
+
//
|
|
69
|
+
// Measurement is per FRAME, not per event. Move events update pointer
|
|
70
|
+
// positions and arena state as they bubble (claims must stay visible
|
|
71
|
+
// synchronously within the dispatch walk), but the cross-pointer measure -
|
|
72
|
+
// centroid, span, angle - waits for the "pointerFrame" terminator the
|
|
73
|
+
// runtime emits after all of a frame's moves have dispatched. At that point
|
|
74
|
+
// every pointer is the same age, so the per-event scissor is gone by
|
|
75
|
+
// construction (measuring on each event paired one fresh position with the
|
|
76
|
+
// other pointers' frame-stale ones, and the span oscillated around its true
|
|
77
|
+
// value under perfectly smooth motion), and one delta emits per frame - the
|
|
78
|
+
// cadence anything painting consumes anyway. Anchors defer to the same
|
|
79
|
+
// terminator for the same reason: a mid-batch anchor would bake one
|
|
80
|
+
// mixed-age jolt into the first delta after it.
|
|
81
|
+
//
|
|
82
|
+
// Residual span noise survives batching, tuned against a captured tablet
|
|
83
|
+
// stream (sensor dither +-1-3px/event - and a resting pair's span WANDERS
|
|
84
|
+
// 28-76px over seconds, human fingers cannot hold separation, so no
|
|
85
|
+
// threshold alone can stay closed):
|
|
86
|
+
// - a slop gate: scale is 1 until the span leaves its gesture baseline by
|
|
87
|
+
// SLOP, so a pan or hold that never really pinches never zooms;
|
|
88
|
+
// - a low-pass: once engaged, scale comes from an EMA of the span
|
|
89
|
+
// (SPAN_SMOOTH). Zero-mean dither averages out; deliberate pinching - a
|
|
90
|
+
// sustained signed drift - passes with ~50ms lag. With the scissor gone
|
|
91
|
+
// this is belt-and-braces; a retirement candidate after an on-device
|
|
92
|
+
// A/B against the raw batched span.
|
|
93
|
+
// - a re-lock: pressed fingertips that HOLD still slowly flatten and roll
|
|
94
|
+
// toward each other, which the sensor reports as a genuine ~9px/s span
|
|
95
|
+
// shrink - a held pinch would zoom out forever. Deliberate pinches
|
|
96
|
+
// measured 100-160px/s, an order of magnitude above creep, so when the
|
|
97
|
+
// smoothed span rate stays under QUIET the gate re-locks and rebases;
|
|
98
|
+
// crossing SLOP from the new base re-engages instantly. No amount of
|
|
99
|
+
// batching removes creep: it is real reported motion.
|
|
100
|
+
// dx/dy have no gate or filter: their noise term is a fraction of a pixel.
|
|
101
|
+
//
|
|
102
|
+
// Set changes rebase: a finger joining (stolen outright - the gesture is
|
|
103
|
+
// already established) or lifting mid-gesture re-anchors the reference
|
|
104
|
+
// configuration, so the change itself never emits a jump delta. The gesture
|
|
105
|
+
// ends when the last finger lifts. Moves and ups arrive on the frozen down
|
|
106
|
+
// path, so an active transform survives leaving the node or the window.
|
|
107
|
+
// cancel() is the external-cancel hook; it ends an active gesture without
|
|
108
|
+
// onTransformEnd. Options are read at event time.
|
|
109
|
+
export function createTransform(options: TransformOptions) {
|
|
110
|
+
// All tracked pointers, in down order (the first two define the rotation pair).
|
|
111
|
+
let pointers = new Map<number, { x: number; y: number }>()
|
|
112
|
+
let active = false
|
|
113
|
+
// Slop baseline while armed; last delivered configuration while active.
|
|
114
|
+
let ref: { x: number; y: number; span: number; angle: number } | null = null
|
|
115
|
+
// The zoom gate and filter (see the zoom-noise note above): scale stays 1
|
|
116
|
+
// until the SMOOTHED span leaves spanBase by SLOP, then streams smoothed
|
|
117
|
+
// ratios for the rest of the gesture.
|
|
118
|
+
let pinch = false
|
|
119
|
+
let spanBase = 0
|
|
120
|
+
let smoothSpan = 0
|
|
121
|
+
// Warm-started at each engage so a fresh pinch cannot instantly re-lock.
|
|
122
|
+
let spanRate = 0
|
|
123
|
+
// Motion arrived this frame; measured and emitted at the terminator.
|
|
124
|
+
let dirty = false
|
|
125
|
+
// The anchor must be retaken at the terminator (activation and set-change
|
|
126
|
+
// rebases): mid-batch the pointer map is mixed-age, so anchoring
|
|
127
|
+
// immediately would bake a jolt into the next delta. A rebase frame
|
|
128
|
+
// emits nothing.
|
|
129
|
+
let rebase = false
|
|
130
|
+
|
|
131
|
+
let measure = () => {
|
|
132
|
+
let n = pointers.size
|
|
133
|
+
let x = 0
|
|
134
|
+
let y = 0
|
|
135
|
+
for (let p of pointers.values()) {
|
|
136
|
+
x += p.x
|
|
137
|
+
y += p.y
|
|
138
|
+
}
|
|
139
|
+
x /= n
|
|
140
|
+
y /= n
|
|
141
|
+
let span = 0
|
|
142
|
+
for (let p of pointers.values()) span += Math.hypot(p.x - x, p.y - y)
|
|
143
|
+
span /= n
|
|
144
|
+
let angle = 0
|
|
145
|
+
if (n >= 2) {
|
|
146
|
+
let pair = pointers.values()
|
|
147
|
+
let a = pair.next().value!
|
|
148
|
+
let b = pair.next().value!
|
|
149
|
+
angle = Math.atan2(b.y - a.y, b.x - a.x)
|
|
150
|
+
}
|
|
151
|
+
return { x, y, span, angle }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let reset = () => {
|
|
155
|
+
if (active) for (let id of pointers.keys()) arena.release(id, owner)
|
|
156
|
+
pointers.clear()
|
|
157
|
+
active = false
|
|
158
|
+
ref = null
|
|
159
|
+
pinch = false
|
|
160
|
+
dirty = false
|
|
161
|
+
rebase = false
|
|
162
|
+
}
|
|
163
|
+
let cancel = reset
|
|
164
|
+
let owner: ArenaOwner = { cancel }
|
|
165
|
+
|
|
166
|
+
// The per-frame measure point: runs at the pointerFrame terminator, when
|
|
167
|
+
// every tracked pointer's position is the same age.
|
|
168
|
+
let flush = () => {
|
|
169
|
+
if (!active) return
|
|
170
|
+
if (rebase) {
|
|
171
|
+
// Anchor from same-age positions; emits nothing - an activation or
|
|
172
|
+
// set change must not produce a jump delta. Motion that arrived in
|
|
173
|
+
// the same batch folds into the anchor.
|
|
174
|
+
ref = measure()
|
|
175
|
+
spanBase = ref.span
|
|
176
|
+
smoothSpan = ref.span
|
|
177
|
+
rebase = false
|
|
178
|
+
dirty = false
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (!dirty || !ref) return
|
|
182
|
+
dirty = false
|
|
183
|
+
let m = measure()
|
|
184
|
+
let prevSpan = smoothSpan
|
|
185
|
+
smoothSpan += (m.span - smoothSpan) * SPAN_SMOOTH
|
|
186
|
+
spanRate += (Math.abs(smoothSpan - prevSpan) - spanRate) * QUIET_SMOOTH
|
|
187
|
+
if (!pinch && Math.abs(smoothSpan - spanBase) >= SLOP) {
|
|
188
|
+
// A slop excursion at speed is a pinch; at creep speed the base
|
|
189
|
+
// just follows, so drift can wander forever without engaging.
|
|
190
|
+
if (spanRate >= ENGAGE) pinch = true
|
|
191
|
+
else spanBase = smoothSpan
|
|
192
|
+
}
|
|
193
|
+
let scale = pinch && prevSpan > 0 && smoothSpan > 0 ? smoothSpan / prevSpan : 1
|
|
194
|
+
if (pinch && spanRate < QUIET) {
|
|
195
|
+
// Holding, not pinching (see the re-lock note above).
|
|
196
|
+
pinch = false
|
|
197
|
+
spanBase = smoothSpan
|
|
198
|
+
}
|
|
199
|
+
let rotation = 0
|
|
200
|
+
if (pointers.size >= 2) {
|
|
201
|
+
rotation = m.angle - ref.angle
|
|
202
|
+
if (rotation > Math.PI) rotation -= 2 * Math.PI
|
|
203
|
+
else if (rotation < -Math.PI) rotation += 2 * Math.PI
|
|
204
|
+
}
|
|
205
|
+
let dx = m.x - ref.x
|
|
206
|
+
let dy = m.y - ref.y
|
|
207
|
+
ref = m
|
|
208
|
+
options.onTransformMove?.({ dx, dy, scale, rotation, x: m.x, y: m.y, pointers: pointers.size })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// An unmount mid-gesture must not leave resolved claims behind (or a live
|
|
212
|
+
// terminator subscription).
|
|
213
|
+
onSettled(() => {
|
|
214
|
+
let unsub = on("pointerFrame", flush)
|
|
215
|
+
return () => {
|
|
216
|
+
unsub()
|
|
217
|
+
reset()
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
let handlers = {
|
|
222
|
+
onPointerDown: (e: PointerEvent) => {
|
|
223
|
+
if (e.button != null && e.button !== 0) return
|
|
224
|
+
if (pointers.has(e.pointerId)) return
|
|
225
|
+
if (active) {
|
|
226
|
+
// A finger joining an established gesture belongs to it outright; if
|
|
227
|
+
// the arena refuses (resolved elsewhere) the finger stays out.
|
|
228
|
+
if (!arena.steal(e.pointerId, owner)) return
|
|
229
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
|
230
|
+
rebase = true
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
|
234
|
+
ref = measure()
|
|
235
|
+
},
|
|
236
|
+
onPointerMove: (e: PointerEvent) => {
|
|
237
|
+
let p = pointers.get(e.pointerId)
|
|
238
|
+
if (!p || !ref) return
|
|
239
|
+
p.x = e.clientX
|
|
240
|
+
p.y = e.clientY
|
|
241
|
+
if (!active) {
|
|
242
|
+
// Arming runs per event: the slop test tolerates a mixed-age
|
|
243
|
+
// measure (8px against <=1 frame of staleness), and the arena
|
|
244
|
+
// steal must happen synchronously inside the dispatch walk.
|
|
245
|
+
let m = measure()
|
|
246
|
+
let travel = Math.hypot(m.x - ref.x, m.y - ref.y)
|
|
247
|
+
if (travel < SLOP && Math.abs(m.span - ref.span) < SLOP) return
|
|
248
|
+
let taken: number[] = []
|
|
249
|
+
let refused = false
|
|
250
|
+
for (let id of pointers.keys()) {
|
|
251
|
+
if (arena.steal(id, owner)) taken.push(id)
|
|
252
|
+
else {
|
|
253
|
+
refused = true
|
|
254
|
+
break
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (refused) {
|
|
258
|
+
// The gesture belongs elsewhere; hand back what we took and disarm.
|
|
259
|
+
for (let id of taken) arena.release(id, owner)
|
|
260
|
+
pointers.clear()
|
|
261
|
+
ref = null
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
active = true
|
|
265
|
+
// A span-driven activation IS a deliberate pinch - engaging the
|
|
266
|
+
// zoom gate now avoids demanding a second slop-crossing from it.
|
|
267
|
+
pinch = Math.abs(m.span - ref.span) >= SLOP
|
|
268
|
+
spanRate = pinch ? 1 : 0
|
|
269
|
+
rebase = true
|
|
270
|
+
options.onTransformStart?.()
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
dirty = true
|
|
274
|
+
},
|
|
275
|
+
onPointerUp: (e: PointerEvent) => {
|
|
276
|
+
if (!pointers.has(e.pointerId)) return
|
|
277
|
+
if (active) {
|
|
278
|
+
arena.release(e.pointerId, owner)
|
|
279
|
+
pointers.delete(e.pointerId)
|
|
280
|
+
if (pointers.size === 0) {
|
|
281
|
+
active = false
|
|
282
|
+
ref = null
|
|
283
|
+
pinch = false
|
|
284
|
+
dirty = false
|
|
285
|
+
rebase = false
|
|
286
|
+
options.onTransformEnd?.()
|
|
287
|
+
} else {
|
|
288
|
+
rebase = true
|
|
289
|
+
pinch = pinch && pointers.size >= 2
|
|
290
|
+
spanRate = 1
|
|
291
|
+
}
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
pointers.delete(e.pointerId)
|
|
295
|
+
ref = pointers.size > 0 ? measure() : null
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { handlers, cancel }
|
|
300
|
+
}
|
package/src/types.d.ts
CHANGED
|
@@ -415,8 +415,10 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
|
|
|
415
415
|
* transform - it never sizes the element, so give the box its size with
|
|
416
416
|
* layout props. Composed innermost: the transform props still operate in
|
|
417
417
|
* box space, and pointer events on children arrive in design coordinates.
|
|
418
|
-
* The
|
|
419
|
-
*
|
|
418
|
+
* The overflow clip and scrollX/scrollY stay box properties too: the clip
|
|
419
|
+
* rect is the layout box and scroll offsets are box pixels, regardless of
|
|
420
|
+
* fit scale. The natural wrapper for parseSvg draws, or any d-* subtree
|
|
421
|
+
* authored in fixed design units.
|
|
420
422
|
*/
|
|
421
423
|
viewBox?: [number, number]
|
|
422
424
|
/**
|
|
@@ -582,11 +584,13 @@ export interface TextureProps extends PaintProps, PointerProps {
|
|
|
582
584
|
srcY?: number
|
|
583
585
|
srcW?: number
|
|
584
586
|
srcH?: number
|
|
585
|
-
// Shader uniform values, when src names a
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
587
|
+
// Shader uniform values, when src names a render target: the same channel
|
|
588
|
+
// setTargetParams drives, written through it directly - prop and
|
|
589
|
+
// imperative writes validate and error identically (an unknown name
|
|
590
|
+
// throws, and set src before params: a write with no src to route to
|
|
591
|
+
// throws). However often a signal writes, the target renders once per
|
|
592
|
+
// frame at the raster flush. A number drives a scalar (`float`/`int`); a
|
|
593
|
+
// flat number array drives the declared GLSL type: 2/3/4 for
|
|
594
|
+
// `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`.
|
|
591
595
|
params?: Record<string, number | number[]>
|
|
592
596
|
}
|
package/src/window.ts
CHANGED
|
@@ -38,26 +38,42 @@ let refreshRate = 60
|
|
|
38
38
|
* the present count, and `rate` is the current refresh rate in Hz. `tick` is paced
|
|
39
39
|
* by the runtime (one refresh period per present, slow-corrected toward the wall
|
|
40
40
|
* clock) so animations driven off it stay smooth even when swap-return times
|
|
41
|
-
* jitter.
|
|
42
|
-
*
|
|
41
|
+
* jitter. performance.now() and timers report/march on this same paced timeline
|
|
42
|
+
* (so the whole time surface freezes together under the dev tools' clock
|
|
43
|
+
* control); for real wall-clock time use Date.now().
|
|
43
44
|
* Returns a cleanup function; also auto-cleans within a reactive scope.
|
|
44
45
|
*/
|
|
45
46
|
export function onFrame(fn: (tick: number, frame: number, rate: number) => void) {
|
|
46
47
|
let frameId: number = null!
|
|
48
|
+
// Cancellation is a flag, not map membership: while a frame runs the whole
|
|
49
|
+
// callback map is swapped out, so a cleanup() called from another onFrame
|
|
50
|
+
// callback in the same tick could not reach this entry through the map.
|
|
51
|
+
let cancelled = false
|
|
47
52
|
|
|
48
53
|
let extendedFn = (tick: number, frame: number, rate: number) => {
|
|
49
|
-
|
|
54
|
+
if (cancelled) return
|
|
55
|
+
// Re-register BEFORE running fn, so a throwing callback stays subscribed
|
|
56
|
+
// (event-listener semantics: the error is reported, the subscription
|
|
57
|
+
// lives) and a cleanup() from inside fn sees the current frameId. A
|
|
58
|
+
// pending onFrame callback is a standing request for the next frame.
|
|
50
59
|
frameId = nextFrameId++
|
|
51
60
|
animationFrames.set(frameId, extendedFn)
|
|
52
|
-
// A pending onFrame callback is a standing request for the next frame.
|
|
53
61
|
requestFrame()
|
|
62
|
+
try {
|
|
63
|
+
fn(tick, frame, rate)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error("Error in onFrame callback:", err)
|
|
66
|
+
}
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
frameId = nextFrameId++
|
|
57
70
|
animationFrames.set(frameId, extendedFn)
|
|
58
71
|
requestFrame()
|
|
59
72
|
|
|
60
|
-
let cleanup = () =>
|
|
73
|
+
let cleanup = () => {
|
|
74
|
+
cancelled = true
|
|
75
|
+
animationFrames.delete(frameId)
|
|
76
|
+
}
|
|
61
77
|
onCleanup(cleanup)
|
|
62
78
|
return cleanup
|
|
63
79
|
}
|
|
@@ -253,7 +269,13 @@ export function attachWindow(nodeId: number) {
|
|
|
253
269
|
animationFrames = new Map()
|
|
254
270
|
for (let fn of frames.values()) fn(t, frame, refreshRate)
|
|
255
271
|
}
|
|
256
|
-
|
|
272
|
+
try {
|
|
273
|
+
// A throwing effect must not skip renderFrame: the frame still paints
|
|
274
|
+
// whatever state committed before the throw.
|
|
275
|
+
flush()
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.error("Error in reactive flush:", err)
|
|
278
|
+
}
|
|
257
279
|
scanForOrphans(t)
|
|
258
280
|
renderFrame()
|
|
259
281
|
}
|
|
@@ -298,7 +320,13 @@ export function attachWindow(nodeId: number) {
|
|
|
298
320
|
e.localY = localY[i]!
|
|
299
321
|
e.parentX = parentX[i]!
|
|
300
322
|
e.parentY = parentY[i]!
|
|
301
|
-
|
|
323
|
+
try {
|
|
324
|
+
getEventHandler(targets[i]!, handler)?.(e)
|
|
325
|
+
} catch (err) {
|
|
326
|
+
// One throwing handler must not suppress delivery to the rest of
|
|
327
|
+
// the path (or the focus/blur step that follows the loop).
|
|
328
|
+
console.error(`Error in ${handler} handler:`, err)
|
|
329
|
+
}
|
|
302
330
|
if (stopped) break
|
|
303
331
|
}
|
|
304
332
|
}
|