@solidrt/core 0.0.57 → 0.0.59
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 +42 -7
- package/docs/index.md +23 -0
- package/examples/README.md +1 -0
- package/examples/gpu-draw-list.tsx +7 -7
- package/examples/gpu-instancing.tsx +15 -11
- package/examples/gpu-particles.tsx +8 -4
- package/examples/gpu-pipeline.tsx +8 -4
- package/examples/gpu-shared-params.tsx +6 -6
- package/examples/gpu-sprites.tsx +11 -7
- package/examples/layout-slide.tsx +78 -0
- package/examples/stagger.tsx +35 -19
- package/package.json +5 -5
- package/src/environment.ts +34 -4
- package/src/gpu.ts +25 -35
- package/src/index.ts +4 -4
- package/src/input-chord.ts +44 -0
- package/src/input-keyboard.ts +10 -21
- package/src/input-map.ts +2 -1
- package/src/input-pointer.ts +153 -60
- package/src/renderer.ts +17 -2
- package/src/runtime-modules.d.ts +11 -4
- package/src/transform.ts +4 -1
- package/src/types.d.ts +70 -24
- package/src/video.ts +1 -1
- package/src/window.ts +146 -10
package/src/gpu.ts
CHANGED
|
@@ -135,7 +135,7 @@ export {
|
|
|
135
135
|
// updated draw range (vertexCount after its buffer gained or lost dynamic
|
|
136
136
|
// geometry, firstVertex for a different window of a shared buffer,
|
|
137
137
|
// instanceCount for an instanced population; absent keys keep their current
|
|
138
|
-
// value, like params) and/or swapped buffers (
|
|
138
|
+
// value, like params) and/or swapped buffers (an instance buffer pointed at a
|
|
139
139
|
// larger buffer once a population outgrows the old one - the growth
|
|
140
140
|
// primitive; replace-only, the range is rechecked); destroyBuffer is the manual
|
|
141
141
|
// cleanup path for buffers created outside a reactive scope. renderTarget is
|
|
@@ -147,7 +147,7 @@ export {
|
|
|
147
147
|
// GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
|
|
148
148
|
// ping-pong buffer, reset state to a known image.
|
|
149
149
|
export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
|
|
150
|
-
export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange,
|
|
150
|
+
export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceOrder, OrderUpdate, ShaderParams, Topology, VertexAttribute, VertexBufferLayout, VertexFormat } from "flux:gpu"
|
|
151
151
|
|
|
152
152
|
// The draw-list verbs, re-exported raw: entries live and die with their draw
|
|
153
153
|
// target (see createDrawTarget below), so there is no per-entry lifetime to
|
|
@@ -366,20 +366,18 @@ export function createShaderTexture(
|
|
|
366
366
|
* texture id is, e.g. `<texture src>`; resize with `setTargetSize`, drive
|
|
367
367
|
* uniforms with `<texture params>` or `setTargetParams`). Many targets may
|
|
368
368
|
* share one pipeline, and creating a target compiles nothing. The target
|
|
369
|
-
* brings the per-target half: size, the concrete
|
|
370
|
-
*
|
|
371
|
-
* `
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
* lives on the pipeline
|
|
382
|
-
* and throws here. Frees the target when the reactive owner is disposed (opt
|
|
369
|
+
* brings the per-target half: size, the concrete `buffers` the pipeline's
|
|
370
|
+
* layouts describe (one id per layout, in declaration order), the draw
|
|
371
|
+
* range (`vertexCount` defaults to the rest of the tightest vertex-step
|
|
372
|
+
* buffer from `firstVertex` on, `instanceCount` repeats it as instances
|
|
373
|
+
* told apart by `gl_InstanceID` and defaults to one per record of the
|
|
374
|
+
* tightest instance-step buffer; a fullscreen pass over an attributeless
|
|
375
|
+
* pipeline is `{ vertexCount: 3 }` with a covering-triangle vertex stage),
|
|
376
|
+
* uniforms, and `clearColor`. An `indexBuffer` + `indexFormat` pair makes
|
|
377
|
+
* the draw indexed (shared vertices stored once), with the range in
|
|
378
|
+
* `firstIndex`/`indexCount` spelling - see IndexBinding/IndexRange. Draw
|
|
379
|
+
* state (`topology`, `blend`, `cull`, `depth`, `depthWrite`) lives on the
|
|
380
|
+
* pipeline and throws here. Frees the target when the reactive owner is disposed (opt
|
|
383
381
|
* out with `autoFree: false`); the pipeline is yours and outlives it.
|
|
384
382
|
*
|
|
385
383
|
* `render: "manual"` makes it a manual target: the runtime never renders it
|
|
@@ -404,11 +402,8 @@ export function createShaderTarget(
|
|
|
404
402
|
params?: gpu.ShaderParams | null,
|
|
405
403
|
opts?: {
|
|
406
404
|
textures?: gpu.TextureBindings
|
|
407
|
-
buffer
|
|
408
|
-
|
|
409
|
-
/** One buffer per instance slot of the pipeline (index = the
|
|
410
|
-
* attributes' `slot`); pass this OR `instanceBuffer`, not both. */
|
|
411
|
-
instanceBuffers?: gpu.BufferId[]
|
|
405
|
+
/** One buffer id per layout of the pipeline, in declaration order. */
|
|
406
|
+
buffers?: gpu.BufferId[]
|
|
412
407
|
/** Draw the instance records in key order (see InstanceOrder). */
|
|
413
408
|
instanceOrder?: gpu.InstanceOrder
|
|
414
409
|
clearColor?: [number, number, number, number]
|
|
@@ -658,9 +653,9 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
|
658
653
|
* texture, returning the texture id (usable anywhere a normal texture id is,
|
|
659
654
|
* e.g. `<texture src>`) - named, like `createShaderTexture`, for what comes
|
|
660
655
|
* back. Unlike `createShaderTexture` the vertex stage is yours:
|
|
661
|
-
* declare `in` attributes matching `opts.
|
|
662
|
-
*
|
|
663
|
-
*
|
|
656
|
+
* declare `in` attributes matching `opts.buffers` (each layout with the
|
|
657
|
+
* {@link createBuffer} id it reads) and your own varyings toward the
|
|
658
|
+
* fragment stage. Clip space is y-down: `gl_Position` y = -1 is the top
|
|
664
659
|
* row of the target and +1 the bottom, so camera-up geometry must negate y
|
|
665
660
|
* (or fold the flip into its projection) to display up. Both sources may
|
|
666
661
|
* reference `iResolution` and any uniform they declare (`float`/`int`
|
|
@@ -679,10 +674,10 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
|
679
674
|
* see DrawRange) defaults to the whole buffer drawn once and can be changed
|
|
680
675
|
* later with `setDraw`; `instanceCount` is the standard answer to particles
|
|
681
676
|
* and repeated meshes, N copies of the range told apart by `gl_InstanceID`
|
|
682
|
-
* in the vertex stage. `
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
677
|
+
* in the vertex stage. A layout with `stepMode: "instance"` gives each
|
|
678
|
+
* instance its own interleaved record - real per-instance state instead of
|
|
679
|
+
* `gl_InstanceID` arithmetic - and `instanceCount` then defaults to one
|
|
680
|
+
* instance per record. An
|
|
686
681
|
* `indexBuffer` + `indexFormat` pair makes the draw
|
|
687
682
|
* indexed (shared vertices stored once), with the range in
|
|
688
683
|
* `firstIndex`/`indexCount` spelling; `opts.cull` discards one face set by
|
|
@@ -701,13 +696,8 @@ export function createPipelineTexture(
|
|
|
701
696
|
params?: gpu.ShaderParams | null,
|
|
702
697
|
opts?: {
|
|
703
698
|
textures?: gpu.TextureBindings
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
instanceAttributes?: gpu.InstanceAttribute[]
|
|
707
|
-
instanceBuffer?: gpu.BufferId
|
|
708
|
-
/** One buffer per instance slot (index = the attributes' `slot`);
|
|
709
|
-
* pass this OR `instanceBuffer`, not both. */
|
|
710
|
-
instanceBuffers?: gpu.BufferId[]
|
|
699
|
+
/** The pipeline's layouts, each with the buffer it reads. */
|
|
700
|
+
buffers?: (gpu.VertexBufferLayout & { buffer: gpu.BufferId })[]
|
|
711
701
|
/** Draw the instance records in key order (see InstanceOrder). */
|
|
712
702
|
instanceOrder?: gpu.InstanceOrder
|
|
713
703
|
topology?: gpu.Topology
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,11 @@ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables,
|
|
|
3
3
|
export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
|
|
4
4
|
export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
|
|
5
5
|
export type { Gradient, GradientStop } from "./color"
|
|
6
|
-
export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, exit } from "./window"
|
|
7
|
-
export type { BackEvent } from "./window"
|
|
6
|
+
export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onSuspend, onQuit, exit, background } from "./window"
|
|
7
|
+
export type { BackEvent, LifecycleHandler } from "./window"
|
|
8
8
|
export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight, lockPointer, pointerLocked } from "./window"
|
|
9
9
|
export { env } from "./environment"
|
|
10
|
-
export type { InputDevices, SystemTheme, Orientation, Visibility } from "./environment"
|
|
10
|
+
export type { InputDevices, SystemTheme, Orientation, Visibility, Launch } from "./environment"
|
|
11
11
|
export { gamepads } from "./gamepad"
|
|
12
12
|
export type { GamepadState } from "./gamepad"
|
|
13
13
|
export { capabilities } from "./capabilities"
|
|
@@ -37,7 +37,7 @@ export type { KeyboardDevice, KeyboardVec2Keys } from "./input-keyboard"
|
|
|
37
37
|
export { gamepad } from "./input-gamepad"
|
|
38
38
|
export type { GamepadDevice } from "./input-gamepad"
|
|
39
39
|
export { createPointerFeed } from "./input-pointer"
|
|
40
|
-
export type { PointerFeed, PointerFeedOptions } from "./input-pointer"
|
|
40
|
+
export type { PointerFeed, PointerFeedOptions, PointerSource } from "./input-pointer"
|
|
41
41
|
export type {
|
|
42
42
|
Element,
|
|
43
43
|
LayoutProps,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Modifier chords, the vocabulary two devices share: a spec names
|
|
2
|
+
// modifiers ahead of what it qualifies ("Shift+Tab" on the keyboard,
|
|
3
|
+
// "Ctrl" on a pointer gesture), the event must carry them, a bare spec
|
|
4
|
+
// ignores them (a shifted W still walks, a Ctrl-drag still drags), and
|
|
5
|
+
// among the specs one event satisfies the most specific wins - Unreal's
|
|
6
|
+
// chord blocker, Three's OrbitControls picking PAN over ROTATE at
|
|
7
|
+
// mousedown; Godot and Unity fire both by default and every app writes
|
|
8
|
+
// the exclusion itself. Which event resolves a chord is the device's
|
|
9
|
+
// call: the keyboard settles each key down, the pointer feed the event
|
|
10
|
+
// that opens a gesture bracket.
|
|
11
|
+
|
|
12
|
+
export type Modifier = "shiftKey" | "ctrlKey" | "altKey" | "metaKey"
|
|
13
|
+
/** The modifier flags every key, pointer and wheel event carries. */
|
|
14
|
+
export type Modified = Record<Modifier, boolean>
|
|
15
|
+
|
|
16
|
+
const MODIFIERS: Record<string, Modifier> = { Shift: "shiftKey", Ctrl: "ctrlKey", Control: "ctrlKey", Alt: "altKey", Meta: "metaKey" }
|
|
17
|
+
// The canonical order a chord is spelled in, so one modifier set has one name.
|
|
18
|
+
const ORDER: Modifier[] = ["shiftKey", "ctrlKey", "altKey", "metaKey"]
|
|
19
|
+
const NAMES: Record<Modifier, string> = { shiftKey: "Shift", ctrlKey: "Ctrl", altKey: "Alt", metaKey: "Meta" }
|
|
20
|
+
|
|
21
|
+
/** The modifiers `parts` name (["Ctrl", "Shift"]), deduplicated, in
|
|
22
|
+
* canonical order; `what` and `text` name the caller and its spec in the
|
|
23
|
+
* throw on an unknown name. */
|
|
24
|
+
export function parseModifiers(what: string, text: string, parts: string[]): Modifier[] {
|
|
25
|
+
let mods = new Set<Modifier>()
|
|
26
|
+
for (let part of parts) {
|
|
27
|
+
let mod = MODIFIERS[part]
|
|
28
|
+
if (!mod) throw new Error(`${what}: unknown modifier "${part}" in "${text}" (Shift, Ctrl, Alt or Meta)`)
|
|
29
|
+
mods.add(mod)
|
|
30
|
+
}
|
|
31
|
+
return ORDER.filter(m => mods.has(m))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The canonical spelling of a modifier set: "Shift+Ctrl"; "" for none. */
|
|
35
|
+
export let chordName = (mods: Modifier[]): string => mods.map(m => NAMES[m]).join("+")
|
|
36
|
+
|
|
37
|
+
/** Of `items`, those whose modifiers the event carries, narrowed to the
|
|
38
|
+
* most specific (the longest modifier lists, all of them on a tie);
|
|
39
|
+
* empty when none match. */
|
|
40
|
+
export function mostSpecific<T>(items: T[], mods: (item: T) => Modifier[], event: Modified): T[] {
|
|
41
|
+
let hits = items.filter(item => mods(item).every(m => event[m]))
|
|
42
|
+
let most = hits.reduce((n, item) => Math.max(n, mods(item).length), 0)
|
|
43
|
+
return hits.filter(item => mods(item).length === most)
|
|
44
|
+
}
|
package/src/input-keyboard.ts
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
// a shifted "W".
|
|
9
9
|
//
|
|
10
10
|
// A spec may name modifiers ahead of the key ("Shift+Tab", "Ctrl+KeyS";
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// -1, not 0, on Shift+Tab;
|
|
15
|
-
//
|
|
11
|
+
// the chord vocabulary of input-chord.ts, shared with the pointer feed):
|
|
12
|
+
// the down must carry them, while a bare spec ignores them (a shifted W
|
|
13
|
+
// still walks). Within one source the most specific matching spec wins
|
|
14
|
+
// a down, so `axis("Shift+Tab", "Tab")` reads -1, not 0, on Shift+Tab;
|
|
15
|
+
// and an up releases on the key alone, so a modifier let go first
|
|
16
|
+
// cannot leave the key stuck.
|
|
16
17
|
//
|
|
17
18
|
// Held state is a count behind a signal, so a source's rate is reactive
|
|
18
19
|
// (a held key wakes a control's frame loop) and a blur - the up that never
|
|
@@ -27,10 +28,8 @@ import { createSignal } from "@solidjs/signals"
|
|
|
27
28
|
import type { KeyEvent } from "./types"
|
|
28
29
|
import type { InputSource } from "./input-map"
|
|
29
30
|
import type { Vec2 } from "./input-axes"
|
|
30
|
-
|
|
31
|
-
type Modifier
|
|
32
|
-
|
|
33
|
-
const MODIFIERS: Record<string, Modifier> = { Shift: "shiftKey", Ctrl: "ctrlKey", Control: "ctrlKey", Alt: "altKey", Meta: "metaKey" }
|
|
31
|
+
import { mostSpecific, parseModifiers } from "./input-chord"
|
|
32
|
+
import type { Modifier } from "./input-chord"
|
|
34
33
|
|
|
35
34
|
// A parsed spec: the text as given (the held-state key and the label),
|
|
36
35
|
// the key it names, and the modifiers the down must carry.
|
|
@@ -41,13 +40,7 @@ function parse(what: string, text: unknown): Spec {
|
|
|
41
40
|
let parts = text.split("+")
|
|
42
41
|
let key = parts.pop()!
|
|
43
42
|
if (key.length === 0) throw new Error(`keyboard.${what}: "${text}" names no key`)
|
|
44
|
-
|
|
45
|
-
for (let part of parts) {
|
|
46
|
-
let mod = MODIFIERS[part]
|
|
47
|
-
if (!mod) throw new Error(`keyboard.${what}: unknown modifier "${part}" in "${text}" (Shift, Ctrl, Alt or Meta)`)
|
|
48
|
-
if (!mods.includes(mod)) mods.push(mod)
|
|
49
|
-
}
|
|
50
|
-
return { text, key, mods }
|
|
43
|
+
return { text, key, mods: parseModifiers(`keyboard.${what}`, text, parts) }
|
|
51
44
|
}
|
|
52
45
|
|
|
53
46
|
// A key matches its physical code or its logical key; a letter code
|
|
@@ -75,11 +68,7 @@ function held(specs: Spec[]) {
|
|
|
75
68
|
// moves "Tab" to "Shift+Tab" and back), an up frees them all.
|
|
76
69
|
let onKey = specs.filter(s => matches(event, s.key))
|
|
77
70
|
for (let s of onKey) down.delete(s.text)
|
|
78
|
-
if (isDown)
|
|
79
|
-
let hits = onKey.filter(s => s.mods.every(m => event[m]))
|
|
80
|
-
let most = hits.reduce((n, s) => Math.max(n, s.mods.length), 0)
|
|
81
|
-
for (let s of hits) if (s.mods.length === most) down.add(s.text)
|
|
82
|
-
}
|
|
71
|
+
if (isDown) for (let s of mostSpecific(onKey, s => s.mods, event)) down.add(s.text)
|
|
83
72
|
setCount(down.size)
|
|
84
73
|
},
|
|
85
74
|
blur() {
|
package/src/input-map.ts
CHANGED
|
@@ -213,7 +213,8 @@ function checkValue(what: string, kind: ActionKind, v: unknown): void {
|
|
|
213
213
|
|
|
214
214
|
function checkSource(source: unknown): InputSource {
|
|
215
215
|
let s = source as InputSource
|
|
216
|
-
|
|
216
|
+
// A pointer gesture is a callable source (`pointer.drag("Ctrl")` is its chord variant).
|
|
217
|
+
if (!s || (typeof s !== "object" && typeof s !== "function") || (s.kind !== "button" && s.kind !== "axis" && s.kind !== "vec2") || typeof s.label !== "string") {
|
|
217
218
|
throw new Error(`createInputMap: not an input source: ${String(source)}`)
|
|
218
219
|
}
|
|
219
220
|
return s
|
package/src/input-pointer.ts
CHANGED
|
@@ -33,6 +33,30 @@
|
|
|
33
33
|
// Brackets fire on the press itself, before the recognizer's slop, so a
|
|
34
34
|
// glide stops the moment a finger lands.
|
|
35
35
|
//
|
|
36
|
+
// Chords: every source is also callable with a modifier spec, and
|
|
37
|
+
// `pointer.drag("Ctrl")` is the variant that fires only when the event
|
|
38
|
+
// opening the gesture carries Ctrl - and then instead of the bare `drag`,
|
|
39
|
+
// as the keyboard's most specific spec wins a key down (input-chord.ts,
|
|
40
|
+
// the one grammar for both devices). That is the every-viewport default,
|
|
41
|
+
// plain drag orbits and Ctrl-drag pans, as one binding each. A chord is
|
|
42
|
+
// resolved once per bracket, at the event that opens it (the down that
|
|
43
|
+
// starts a drag, the second down that starts pan/pinch/twist, the up
|
|
44
|
+
// that hands the gesture back to drag), and held for the bracket, so a
|
|
45
|
+
// modifier tapped mid-drag cannot split one gesture across two actions
|
|
46
|
+
// (Three decides at mousedown; Unreal's continuous chord splits). The
|
|
47
|
+
// unbracketed wheel and mouseDelta resolve per event. Touch carries no
|
|
48
|
+
// modifiers, so a chord binding is the desktop path and the bare one
|
|
49
|
+
// stays for fingers.
|
|
50
|
+
//
|
|
51
|
+
// Buttons: a bracketed spec may end in the button that opens it, as a
|
|
52
|
+
// key spec ends in its key - `pointer.drag("Right")`, `drag("Shift+Middle")`
|
|
53
|
+
// (Left, Middle, Right; bare means Left) - so Three's right-drag pan is
|
|
54
|
+
// a binding too. A button is a discriminator, not a modifier: a right
|
|
55
|
+
// drag feeds only Right specs, and the chord rule picks among those. One
|
|
56
|
+
// button per pointer: a second button pressed while the first is held
|
|
57
|
+
// joins nothing, and only the held button's release closes the gesture.
|
|
58
|
+
// The wheel and mouseDelta have no button; a button word on them throws.
|
|
59
|
+
//
|
|
36
60
|
// The element's laid-out box normalizes the travel: read at the press
|
|
37
61
|
// through getLayoutBox (the untransformed read, so a designSize fit or
|
|
38
62
|
// an ancestor transform never skews it), or from `layout` when given -
|
|
@@ -49,6 +73,8 @@ import { pointerLocked } from "./window"
|
|
|
49
73
|
import type { PointerEvent, WheelEvent } from "./types"
|
|
50
74
|
import type { DeltaSink, InputSource } from "./input-map"
|
|
51
75
|
import type { Vec2 } from "./input-axes"
|
|
76
|
+
import { chordName, mostSpecific, parseModifiers } from "./input-chord"
|
|
77
|
+
import type { Modified, Modifier } from "./input-chord"
|
|
52
78
|
|
|
53
79
|
// Octaves per wheel-delta unit: the zoom exponent both cameras used
|
|
54
80
|
// (0.0015 per unit) taken in log2, so a 100-unit notch zooms about a
|
|
@@ -60,6 +86,16 @@ const WHEEL_OCTAVES = 0.0015 / Math.LN2
|
|
|
60
86
|
// turn is the ~0.002 rad/px of Three's PointerLockControls.
|
|
61
87
|
const MOUSE_PX_PER_UNIT = 1500
|
|
62
88
|
|
|
89
|
+
/**
|
|
90
|
+
* A pointer gesture as a source. Bound as is, it fires on the primary
|
|
91
|
+
* button under any modifiers; called with a spec ("Ctrl", "Shift+Alt",
|
|
92
|
+
* "Right", "Shift+Middle") it is the variant that fires only when the
|
|
93
|
+
* event opening the gesture carries those modifiers and that button, and
|
|
94
|
+
* then instead of the bare one: the most specific bound chord wins. The
|
|
95
|
+
* same spec returns the same source, so unbind by identity works.
|
|
96
|
+
*/
|
|
97
|
+
export type PointerSource<K extends "axis" | "vec2"> = InputSource<K> & ((chord: string) => InputSource<K>)
|
|
98
|
+
|
|
63
99
|
export interface PointerFeed {
|
|
64
100
|
/** Spread on the element whose pointer events feed the sources. */
|
|
65
101
|
handlers: {
|
|
@@ -70,20 +106,20 @@ export interface PointerFeed {
|
|
|
70
106
|
onWheel(event: WheelEvent): void
|
|
71
107
|
}
|
|
72
108
|
/** One pointer's travel, in element heights; bracketed. */
|
|
73
|
-
drag:
|
|
109
|
+
drag: PointerSource<"vec2">
|
|
74
110
|
/** Two or more pointers' focal travel, in element heights; bracketed. */
|
|
75
|
-
pan:
|
|
111
|
+
pan: PointerSource<"vec2">
|
|
76
112
|
/** Span change of two pointers, in octaves (spreading positive), with
|
|
77
113
|
* the focal point; bracketed. */
|
|
78
|
-
pinch:
|
|
114
|
+
pinch: PointerSource<"axis">
|
|
79
115
|
/** Rotation of the pointer pair, in turns, with the focal point; bracketed. */
|
|
80
|
-
twist:
|
|
116
|
+
twist: PointerSource<"axis">
|
|
81
117
|
/** Wheel notches in octaves (wheel up positive), with the cursor as the
|
|
82
118
|
* focal point; unbracketed. */
|
|
83
|
-
wheel:
|
|
119
|
+
wheel: PointerSource<"axis">
|
|
84
120
|
/** Raw mouse motion while the pointer is locked, in view-height
|
|
85
121
|
* equivalents (see MOUSE_PX_PER_UNIT); unbracketed. */
|
|
86
|
-
mouseDelta:
|
|
122
|
+
mouseDelta: PointerSource<"vec2">
|
|
87
123
|
}
|
|
88
124
|
|
|
89
125
|
export interface PointerFeedOptions {
|
|
@@ -93,33 +129,86 @@ export interface PointerFeedOptions {
|
|
|
93
129
|
layout?: () => { width: number; height: number } | null
|
|
94
130
|
}
|
|
95
131
|
|
|
96
|
-
|
|
132
|
+
// Button names a bracketed spec may end in, to the event's button index.
|
|
133
|
+
const BUTTONS: Record<string, number> = { Left: 0, Middle: 1, Right: 2 }
|
|
134
|
+
const PRIMARY = 0
|
|
97
135
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
// One gesture's sinks, bucketed by spec (the bare source is the empty
|
|
137
|
+
// chord on the primary button). A bracket opens the buckets of the
|
|
138
|
+
// button that opened it whose modifiers the opening event carries,
|
|
139
|
+
// narrowed to the most specific among those with a sink bound, and
|
|
140
|
+
// delivers to them until it closes; an unbracketed send resolves the
|
|
141
|
+
// same way per event. Sinks are live sets, as before chords: a consumer
|
|
142
|
+
// bound mid-gesture joins it.
|
|
143
|
+
type Bucket = { mods: Modifier[]; button: number; sinks: Set<DeltaSink>; source: InputSource }
|
|
144
|
+
|
|
145
|
+
function gesture<K extends "axis" | "vec2">(kind: K, label: string, buttons: boolean) {
|
|
146
|
+
let buckets = new Map<string, Bucket>()
|
|
147
|
+
let open: Set<DeltaSink>[] = []
|
|
148
|
+
let variant = (mods: Modifier[], button: number): InputSource<K> => {
|
|
149
|
+
let name = [chordName(mods), button === PRIMARY ? "" : Object.keys(BUTTONS)[button]!].filter(Boolean).join("+")
|
|
150
|
+
let bucket = buckets.get(name)
|
|
151
|
+
if (!bucket) {
|
|
152
|
+
let sinks = new Set<DeltaSink>()
|
|
153
|
+
let source: InputSource<K> = {
|
|
154
|
+
kind,
|
|
155
|
+
label: name ? `${label} (${name})` : label,
|
|
156
|
+
deltas(sink) {
|
|
157
|
+
sinks.add(sink)
|
|
158
|
+
return () => {
|
|
159
|
+
sinks.delete(sink)
|
|
160
|
+
}
|
|
161
|
+
},
|
|
106
162
|
}
|
|
163
|
+
bucket = { mods, button, sinks, source }
|
|
164
|
+
buckets.set(name, bucket)
|
|
165
|
+
}
|
|
166
|
+
return bucket.source as InputSource<K>
|
|
167
|
+
}
|
|
168
|
+
let bare = variant([], PRIMARY)
|
|
169
|
+
let spec = (text: string): InputSource<K> => {
|
|
170
|
+
if (typeof text !== "string" || text.length === 0) throw new Error(`${label}: expected a spec such as "Ctrl", "Shift+Alt"${buttons ? ', "Right" or "Shift+Middle"' : ""}, got ${String(text)}`)
|
|
171
|
+
let parts = text.split("+")
|
|
172
|
+
let button = PRIMARY
|
|
173
|
+
if (parts[parts.length - 1]! in BUTTONS) {
|
|
174
|
+
if (!buttons) throw new Error(`${label}: has no button, but "${text}" names one`)
|
|
175
|
+
button = BUTTONS[parts.pop()!]!
|
|
176
|
+
}
|
|
177
|
+
return variant(parseModifiers(label, text, parts), button)
|
|
178
|
+
}
|
|
179
|
+
let source: PointerSource<K> = Object.assign(spec, { kind, label: bare.label, deltas: bare.deltas! })
|
|
180
|
+
let resolve = (event: Modified, button: number): Set<DeltaSink>[] =>
|
|
181
|
+
mostSpecific(
|
|
182
|
+
[...buckets.values()].filter(b => b.sinks.size > 0 && b.button === button),
|
|
183
|
+
b => b.mods,
|
|
184
|
+
event,
|
|
185
|
+
).map(b => b.sinks)
|
|
186
|
+
return {
|
|
187
|
+
source,
|
|
188
|
+
begin(event: Modified, button: number) {
|
|
189
|
+
open = resolve(event, button)
|
|
190
|
+
for (let sinks of open) sinks.forEach(k => k.begin())
|
|
191
|
+
},
|
|
192
|
+
delta(value: number | Vec2, focal?: Vec2) {
|
|
193
|
+
for (let sinks of open) sinks.forEach(k => k.delta(value, focal))
|
|
194
|
+
},
|
|
195
|
+
end() {
|
|
196
|
+
for (let sinks of open) sinks.forEach(k => k.end())
|
|
197
|
+
open = []
|
|
198
|
+
},
|
|
199
|
+
send(event: Modified, value: number | Vec2, focal?: Vec2) {
|
|
200
|
+
for (let sinks of resolve(event, PRIMARY)) sinks.forEach(k => k.delta(value, focal))
|
|
107
201
|
},
|
|
108
202
|
}
|
|
109
203
|
}
|
|
110
204
|
|
|
111
205
|
export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed {
|
|
112
|
-
let
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
mouseDelta: new Set<DeltaSink>(),
|
|
119
|
-
}
|
|
120
|
-
let begin = (s: Sinks) => s.forEach(k => k.begin())
|
|
121
|
-
let end = (s: Sinks) => s.forEach(k => k.end())
|
|
122
|
-
let delta = (s: Sinks, value: number | Vec2, focal?: Vec2) => s.forEach(k => k.delta(value, focal))
|
|
206
|
+
let drag = gesture("vec2", "pointer drag", true)
|
|
207
|
+
let pan = gesture("vec2", "pointer two-finger pan", true)
|
|
208
|
+
let pinch = gesture("axis", "pointer pinch", true)
|
|
209
|
+
let twist = gesture("axis", "pointer twist", true)
|
|
210
|
+
let wheel = gesture("axis", "pointer wheel", false)
|
|
211
|
+
let mouseDelta = gesture("vec2", "mouse motion (pointer locked)", false)
|
|
123
212
|
|
|
124
213
|
// The element's box, read at each press (see the header). A detached
|
|
125
214
|
// leaf has no layout box and must bring `layout`: normalizing by
|
|
@@ -138,76 +227,80 @@ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed
|
|
|
138
227
|
let focalOf = (x: number, y: number): Vec2 | undefined => (box.width > 0 && box.height > 0 ? [x / box.width, y / box.height] : undefined)
|
|
139
228
|
|
|
140
229
|
let transform = createTransform({
|
|
230
|
+
buttons: "any",
|
|
141
231
|
onTransformMove: t => {
|
|
142
232
|
let h = unit()
|
|
143
233
|
let travel: Vec2 = [t.dx / h, t.dy / h]
|
|
144
234
|
if (t.pointers >= 2) {
|
|
145
|
-
if (t.dx !== 0 || t.dy !== 0) delta(
|
|
235
|
+
if (t.dx !== 0 || t.dy !== 0) pan.delta(travel)
|
|
146
236
|
let focal = focalOf(t.x, t.y)
|
|
147
|
-
if (t.scale !== 1) delta(
|
|
148
|
-
if (t.rotation !== 0) delta(
|
|
237
|
+
if (t.scale !== 1) pinch.delta(Math.log2(t.scale), focal)
|
|
238
|
+
if (t.rotation !== 0) twist.delta(t.rotation / (2 * Math.PI), focal)
|
|
149
239
|
} else if (t.dx !== 0 || t.dy !== 0) {
|
|
150
|
-
delta(
|
|
240
|
+
drag.delta(travel)
|
|
151
241
|
}
|
|
152
242
|
},
|
|
153
243
|
})
|
|
154
244
|
|
|
155
|
-
// Pointers down on the element
|
|
156
|
-
// press opens drag, a second closes it
|
|
157
|
-
// gestures, and the count coming back down
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
245
|
+
// Pointers down on the element with the button that pressed them, for
|
|
246
|
+
// the gesture brackets: the first press opens drag, a second closes it
|
|
247
|
+
// and opens the two-pointer gestures, and the count coming back down
|
|
248
|
+
// reverses that. The event that opens a bracket resolves its chord,
|
|
249
|
+
// and the pointer it opens for supplies the button (the drag that
|
|
250
|
+
// resumes when a second pointer lifts belongs to the one still down).
|
|
251
|
+
let downs = new Map<number, number>()
|
|
252
|
+
let landed = (e: PointerEvent, button: number) => {
|
|
253
|
+
downs.set(e.pointerId, button)
|
|
254
|
+
if (downs.size === 1) drag.begin(e, button)
|
|
163
255
|
if (downs.size === 2) {
|
|
164
|
-
end(
|
|
165
|
-
begin(
|
|
166
|
-
begin(
|
|
167
|
-
begin(
|
|
256
|
+
drag.end()
|
|
257
|
+
pan.begin(e, button)
|
|
258
|
+
pinch.begin(e, button)
|
|
259
|
+
twist.begin(e, button)
|
|
168
260
|
}
|
|
169
261
|
}
|
|
170
|
-
let lifted = (
|
|
171
|
-
|
|
262
|
+
let lifted = (e: PointerEvent) => {
|
|
263
|
+
downs.delete(e.pointerId)
|
|
172
264
|
if (downs.size === 1) {
|
|
173
|
-
end(
|
|
174
|
-
end(
|
|
175
|
-
end(
|
|
176
|
-
begin(
|
|
265
|
+
pan.end()
|
|
266
|
+
pinch.end()
|
|
267
|
+
twist.end()
|
|
268
|
+
drag.begin(e, [...downs.values()][0]!)
|
|
177
269
|
}
|
|
178
|
-
if (downs.size === 0) end(
|
|
270
|
+
if (downs.size === 0) drag.end()
|
|
179
271
|
}
|
|
180
272
|
|
|
181
273
|
return {
|
|
182
274
|
handlers: {
|
|
183
275
|
onPointerDown(e) {
|
|
184
|
-
if (
|
|
276
|
+
if (downs.has(e.pointerId)) return
|
|
185
277
|
if (downs.size === 0) box = size(e)
|
|
186
|
-
landed(e.
|
|
278
|
+
landed(e, e.button ?? PRIMARY)
|
|
187
279
|
transform.handlers.onPointerDown(e)
|
|
188
280
|
},
|
|
189
281
|
onPointerMove(e) {
|
|
190
282
|
if (pointerLocked() && e.pointerType === "mouse" && (e.movementX !== 0 || e.movementY !== 0)) {
|
|
191
|
-
|
|
283
|
+
mouseDelta.send(e, [e.movementX / MOUSE_PX_PER_UNIT, e.movementY / MOUSE_PX_PER_UNIT])
|
|
192
284
|
}
|
|
193
285
|
transform.handlers.onPointerMove(e)
|
|
194
286
|
},
|
|
195
287
|
onPointerUp(e) {
|
|
288
|
+
if (downs.get(e.pointerId) !== (e.button ?? PRIMARY)) return
|
|
196
289
|
transform.handlers.onPointerUp(e)
|
|
197
|
-
lifted(e
|
|
290
|
+
lifted(e)
|
|
198
291
|
},
|
|
199
292
|
onPointerLeave() {},
|
|
200
293
|
onWheel(e) {
|
|
201
294
|
if (e.deltaY === 0) return
|
|
202
295
|
box = size(e)
|
|
203
|
-
|
|
296
|
+
wheel.send(e, -e.deltaY * WHEEL_OCTAVES, focalOf(e.localX, e.localY))
|
|
204
297
|
},
|
|
205
298
|
},
|
|
206
|
-
drag:
|
|
207
|
-
pan:
|
|
208
|
-
pinch:
|
|
209
|
-
twist:
|
|
210
|
-
wheel:
|
|
211
|
-
mouseDelta: source
|
|
299
|
+
drag: drag.source,
|
|
300
|
+
pan: pan.source,
|
|
301
|
+
pinch: pinch.source,
|
|
302
|
+
twist: twist.source,
|
|
303
|
+
wheel: wheel.source,
|
|
304
|
+
mouseDelta: mouseDelta.source,
|
|
212
305
|
}
|
|
213
306
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createRoot, onCleanup, NotReadyError } from "@solidjs/signals"
|
|
1
|
+
import { createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
|
|
2
2
|
import { createRenderer } from "@solidjs/universal"
|
|
3
3
|
import { createErrorBoundary } from "solid-js"
|
|
4
4
|
import type { Element } from "solid-js"
|
|
@@ -51,9 +51,13 @@ let destroyScheduled = false
|
|
|
51
51
|
// before dropping handlers so onBlur still fires for a focused descendant.
|
|
52
52
|
function destroyNode(node: ProxyNode): void {
|
|
53
53
|
tree.destroyNode(node.id)
|
|
54
|
+
// Read once, untracked: destroyNode runs inside effect apply phases (the
|
|
55
|
+
// root boundary's swap, the end-of-tick sweep), where a per-node signal
|
|
56
|
+
// read is a STRICT_READ_UNTRACKED warning per node.
|
|
57
|
+
let focused = untrack(focusedNode)
|
|
54
58
|
let cleanup = (n: ProxyNode) => {
|
|
55
59
|
for (let child of n.children) if (child.parent === n) cleanup(child)
|
|
56
|
-
if (n.id ===
|
|
60
|
+
if (n.id === focused) setFocus(null)
|
|
57
61
|
nodes.delete(n.id)
|
|
58
62
|
cleanupNode(n.id)
|
|
59
63
|
}
|
|
@@ -288,6 +292,17 @@ let renderer = createRenderer<ProxyNode>({
|
|
|
288
292
|
if (anchor) tree.insertNode(parent.id, node.id, anchor.id)
|
|
289
293
|
else tree.insertNode(parent.id, node.id)
|
|
290
294
|
|
|
295
|
+
// A bare move - Solid reorders a <For> with insertBefore on a node
|
|
296
|
+
// that is still attached, no removeNode first - unlinks the node from
|
|
297
|
+
// wherever it was on the native side (a same-parent reorder included).
|
|
298
|
+
// The mirror must drop the old entry too, or it keeps both and
|
|
299
|
+
// getNextSibling hands Solid stale anchors from then on, so every
|
|
300
|
+
// later insert lands in the wrong place.
|
|
301
|
+
let previous = node.parent
|
|
302
|
+
if (previous) {
|
|
303
|
+
let at = previous.children.indexOf(node)
|
|
304
|
+
if (at !== -1) previous.children.splice(at, 1)
|
|
305
|
+
}
|
|
291
306
|
node.parent = parent
|
|
292
307
|
|
|
293
308
|
if (!anchor) {
|
package/src/runtime-modules.d.ts
CHANGED
|
@@ -66,12 +66,19 @@ declare module "srt:events" {
|
|
|
66
66
|
// The running application's own surface (lattice), present in every build.
|
|
67
67
|
declare module "srt:app" {
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
* client, quit when standalone or at the player root (on
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* End the current app now, without running the quit hooks: back to the
|
|
70
|
+
* player in a dev client, quit when standalone or at the player root (on
|
|
71
|
+
* Android the activity finishes). Prefer the @solidrt/core `exit`, which
|
|
72
|
+
* runs `onQuit` first.
|
|
73
73
|
*/
|
|
74
74
|
export function exit(): void
|
|
75
|
+
/**
|
|
76
|
+
* Leave the current app without ending it: back to the player in a dev
|
|
77
|
+
* client; otherwise to the OS background (Android moveTaskToBack, a
|
|
78
|
+
* minimized window on desktop). Core's default action of an unprevented
|
|
79
|
+
* `back` on Android; exposed there as `background`.
|
|
80
|
+
*/
|
|
81
|
+
export function background(): void
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
// Dev-server control surface (lattice). Present only in dev/go builds; in other
|
package/src/transform.ts
CHANGED
|
@@ -47,6 +47,9 @@ export type TransformDelta = {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export interface TransformOptions {
|
|
50
|
+
/** Which mouse buttons open a gesture: the primary only (default), or
|
|
51
|
+
* any - the pointer feed's choice, which tells its buttons apart itself. */
|
|
52
|
+
buttons?: "primary" | "any"
|
|
50
53
|
onTransformStart?: () => void
|
|
51
54
|
/** Streams one delta per frame; compose them multiplicatively (scale) / additively (dx, dy, rotation). */
|
|
52
55
|
onTransformMove?: (t: TransformDelta) => void
|
|
@@ -251,7 +254,7 @@ export function createTransform(options: TransformOptions) {
|
|
|
251
254
|
|
|
252
255
|
let handlers = {
|
|
253
256
|
onPointerDown: (e: PointerEvent) => {
|
|
254
|
-
if (e.button != null && e.button !== 0) return
|
|
257
|
+
if (options.buttons !== "any" && e.button != null && e.button !== 0) return
|
|
255
258
|
if (pointers.has(e.pointerId)) return
|
|
256
259
|
if (active) {
|
|
257
260
|
// A finger joining an established gesture belongs to it outright; if
|