@solidrt/core 0.0.59 → 0.0.61
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 +100 -16
- package/agents/performance.md +5 -1
- package/examples/README.md +2 -1
- package/examples/gestures.tsx +197 -0
- package/examples/layout-slide.tsx +44 -15
- package/package.json +6 -5
- package/src/arena.ts +55 -0
- package/src/audio.ts +4 -3
- package/src/camera-control.ts +428 -0
- package/src/cursor.ts +113 -0
- package/src/double-tap.ts +150 -0
- package/src/gesture.ts +32 -0
- package/src/index.ts +16 -2
- package/src/input-axes.ts +12 -7
- package/src/input-chord.ts +3 -0
- package/src/input-gamepad-device.ts +115 -6
- package/src/input-id.ts +92 -0
- package/src/input-keyboard.ts +49 -2
- package/src/input-map.ts +268 -68
- package/src/input-pointer.ts +240 -16
- package/src/input-processors.ts +214 -0
- package/src/input.ts +4 -2
- package/src/long-press.ts +137 -0
- package/src/pan.ts +23 -4
- package/src/renderer.ts +16 -6
- package/src/runtime-modules.d.ts +4 -0
- package/src/svg.ts +1 -0
- package/src/swipe.ts +130 -0
- package/src/text-input.ts +104 -46
- package/src/transform.ts +23 -3
- package/src/types.d.ts +44 -7
- package/src/velocity.ts +134 -0
- package/src/video.ts +82 -15
- package/src/window.ts +16 -9
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The long-press recognizer: a pointer held LONG_PRESS_MS without leaving
|
|
2
|
+
// TAP_SLOP of its down fires while still down, then streams moves until
|
|
3
|
+
// the lift (drag-after-long-press is the reorder idiom: the finger that
|
|
4
|
+
// held a row now carries it).
|
|
5
|
+
//
|
|
6
|
+
// Arena: the down arms silently, no claim. A claim at the down would be
|
|
7
|
+
// refused wherever a press already holds the pointer (the same node, or
|
|
8
|
+
// an inner pressable), and the wanted behaviour there is the press
|
|
9
|
+
// showing pressed and the long-press winning at the timer, so the steal
|
|
10
|
+
// happens at the timer instead: the press is cancelled (its feedback
|
|
11
|
+
// retracts, it never fires), an ancestor pan can no longer take the
|
|
12
|
+
// finger, moves stream to the consumer. A pan that crossed its slop first
|
|
13
|
+
// resolved the arena, so the timer's steal is refused and the recognizer
|
|
14
|
+
// stands down: scrolling a list of long-pressable rows scrolls. Nested
|
|
15
|
+
// long-presses resolve innermost-first for free: the inner timer was
|
|
16
|
+
// registered first in the leaf-to-root walk. Options are read at event
|
|
17
|
+
// time; cancel() retracts without an end; an unmount clears the timer and
|
|
18
|
+
// the claim. Mouse and touch alike (a held button long-presses).
|
|
19
|
+
//
|
|
20
|
+
// The hold timer itself (createHoldTimer) is shared with the pointer
|
|
21
|
+
// feed's longPress source, which pulses instead of stealing: the feed is
|
|
22
|
+
// one arena claimant (its transform) and must stay so.
|
|
23
|
+
|
|
24
|
+
import { onSettled } from "@solidjs/signals"
|
|
25
|
+
import type { PointerEvent } from "./types"
|
|
26
|
+
import { arena, type ArenaOwner } from "./arena"
|
|
27
|
+
import { pastSlop, pointAt, type PointerPoint } from "./gesture"
|
|
28
|
+
|
|
29
|
+
// Hold before a long-press fires: Flutter, iOS and Android's upper value.
|
|
30
|
+
const LONG_PRESS_MS = 500
|
|
31
|
+
|
|
32
|
+
export interface HoldTimerOptions {
|
|
33
|
+
/** Read when the timer is armed. */
|
|
34
|
+
ms?: () => number | undefined
|
|
35
|
+
onFire: (pointerId: number, at: PointerPoint, event: { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; button?: number }) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The arm / slop / timer machine under a long-press: one pointer at a
|
|
39
|
+
* time, disarmed by travel, by a lift, by a second pointer or by cancel(). */
|
|
40
|
+
export function createHoldTimer(options: HoldTimerOptions) {
|
|
41
|
+
let armed: { id: number; at: PointerPoint; mods: { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; button?: number } } | null = null
|
|
42
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
43
|
+
let disarm = () => {
|
|
44
|
+
if (timer !== null) clearTimeout(timer)
|
|
45
|
+
timer = null
|
|
46
|
+
armed = null
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
/** The pointer armed right now, if any. */
|
|
50
|
+
armed: () => armed?.id ?? null,
|
|
51
|
+
down(e: PointerEvent) {
|
|
52
|
+
if (armed) {
|
|
53
|
+
// A second finger is not a long-press.
|
|
54
|
+
disarm()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
let ms = options.ms?.() ?? LONG_PRESS_MS
|
|
58
|
+
armed = { id: e.pointerId, at: pointAt(e), mods: { shiftKey: e.shiftKey, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey, button: e.button } }
|
|
59
|
+
timer = setTimeout(() => {
|
|
60
|
+
timer = null
|
|
61
|
+
let a = armed!
|
|
62
|
+
armed = null
|
|
63
|
+
options.onFire(a.id, a.at, a.mods)
|
|
64
|
+
}, ms)
|
|
65
|
+
},
|
|
66
|
+
move(e: PointerEvent) {
|
|
67
|
+
if (armed && armed.id === e.pointerId && pastSlop(e, armed.at)) disarm()
|
|
68
|
+
},
|
|
69
|
+
up(e: PointerEvent) {
|
|
70
|
+
if (armed && armed.id === e.pointerId) disarm()
|
|
71
|
+
},
|
|
72
|
+
cancel: disarm,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface LongPressOptions {
|
|
77
|
+
/** Hold before the fire, default 500 ms. */
|
|
78
|
+
ms?: number
|
|
79
|
+
/** Fired at the timer while the pointer is still down, with the down's
|
|
80
|
+
* position. */
|
|
81
|
+
onLongPress?: (at: PointerPoint) => void
|
|
82
|
+
/** Movement since the previous event after the fire, in the handler
|
|
83
|
+
* node's parent frame (createPan's frame rule). */
|
|
84
|
+
onLongPressMove?: (dx: number, dy: number) => void
|
|
85
|
+
/** The lift after a fire. */
|
|
86
|
+
onLongPressEnd?: () => void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createLongPress(options: LongPressOptions) {
|
|
90
|
+
// The pointer the long-press won, and its last delivered parent position.
|
|
91
|
+
let fired: number | null = null
|
|
92
|
+
let last: { x: number; y: number } | null = null
|
|
93
|
+
let hold = createHoldTimer({
|
|
94
|
+
ms: () => options.ms,
|
|
95
|
+
onFire: (id, at) => {
|
|
96
|
+
if (!arena.steal(id, owner)) return
|
|
97
|
+
fired = id
|
|
98
|
+
last = { x: at.parentX, y: at.parentY }
|
|
99
|
+
options.onLongPress?.(at)
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
let cancel = () => {
|
|
103
|
+
hold.cancel()
|
|
104
|
+
if (fired !== null) {
|
|
105
|
+
arena.release(fired, owner)
|
|
106
|
+
fired = null
|
|
107
|
+
last = null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let owner: ArenaOwner = { cancel }
|
|
111
|
+
onSettled(() => cancel)
|
|
112
|
+
|
|
113
|
+
let handlers = {
|
|
114
|
+
onPointerDown: (e: PointerEvent) => {
|
|
115
|
+
if (e.button != null && e.button !== 0) return
|
|
116
|
+
if (fired !== null) return
|
|
117
|
+
hold.down(e)
|
|
118
|
+
},
|
|
119
|
+
onPointerMove: (e: PointerEvent) => {
|
|
120
|
+
if (fired === e.pointerId && last) {
|
|
121
|
+
options.onLongPressMove?.(e.parentX - last.x, e.parentY - last.y)
|
|
122
|
+
last = { x: e.parentX, y: e.parentY }
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
hold.move(e)
|
|
126
|
+
},
|
|
127
|
+
onPointerUp: (e: PointerEvent) => {
|
|
128
|
+
if (fired === e.pointerId) {
|
|
129
|
+
cancel()
|
|
130
|
+
options.onLongPressEnd?.()
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
hold.up(e)
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
return { handlers, cancel }
|
|
137
|
+
}
|
package/src/pan.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { onSettled } from "@solidjs/signals"
|
|
2
2
|
import type { PointerEvent } from "./types"
|
|
3
3
|
import { arena } from "./arena"
|
|
4
|
+
import { createVelocityTracker, flingVelocity } from "./velocity"
|
|
5
|
+
import type { Velocity } from "./velocity"
|
|
4
6
|
|
|
5
7
|
// Movement in logical pixels before a pan activates. Below this a drag still
|
|
6
8
|
// reads as a press (tap wiggle); crossing it is the positive evidence that the
|
|
@@ -26,7 +28,13 @@ export interface PanOptions {
|
|
|
26
28
|
* Positive dx is rightward, positive dy downward.
|
|
27
29
|
*/
|
|
28
30
|
onPanMove?: (dx: number, dy: number) => void
|
|
29
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The lift, with the pointer's velocity at it in the same parent-frame
|
|
33
|
+
* pixels per second (a least-squares fit over the last 100 ms, see
|
|
34
|
+
* velocity.ts): the fling fact for whoever animates on. Zero when the
|
|
35
|
+
* finger rested before lifting or moved under FLING_MIN_VELOCITY.
|
|
36
|
+
*/
|
|
37
|
+
onPanEnd?: (velocity: Velocity) => void
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
// The pan recognizer: turns a drag into a movement-delta stream. On a down it
|
|
@@ -36,8 +44,11 @@ export interface PanOptions {
|
|
|
36
44
|
// resolving it so no other recognizer can take the drag over. If the arena is
|
|
37
45
|
// already resolved (an inner pan won first) the recognizer disarms and stays
|
|
38
46
|
// out. The slop distance itself is swallowed: deltas stream from the
|
|
39
|
-
// activation point on
|
|
40
|
-
//
|
|
47
|
+
// activation point on, and the up carries the lift velocity (velocity.ts: a
|
|
48
|
+
// fit over the last 100 ms of positions, zero after a rest or under the
|
|
49
|
+
// fling minimum) for momentum. Moves and the up arrive on the frozen down
|
|
50
|
+
// path, so an active pan keeps streaming when the pointer leaves the node
|
|
51
|
+
// or the window.
|
|
41
52
|
// cancel() is the external-cancel hook; it ends an active pan without
|
|
42
53
|
// onPanEnd. Options are read at event time. Single-pointer by design; for
|
|
43
54
|
// multi-pointer pinch/rotate compose createTransform instead.
|
|
@@ -55,6 +66,8 @@ export function createPan(options: PanOptions) {
|
|
|
55
66
|
let origin: { x: number; y: number } | null = null
|
|
56
67
|
let active: number | null = null
|
|
57
68
|
let armed: number | null = null
|
|
69
|
+
// Parent-frame positions of the active pointer, for the lift's velocity.
|
|
70
|
+
let tracker = createVelocityTracker()
|
|
58
71
|
|
|
59
72
|
let past = (e: PointerEvent) => {
|
|
60
73
|
if (!origin) return false
|
|
@@ -94,6 +107,8 @@ export function createPan(options: PanOptions) {
|
|
|
94
107
|
active = e.pointerId
|
|
95
108
|
armed = null
|
|
96
109
|
origin = { x: e.parentX, y: e.parentY }
|
|
110
|
+
tracker.reset()
|
|
111
|
+
tracker.push(e.parentX, e.parentY)
|
|
97
112
|
options.onPanStart?.()
|
|
98
113
|
} else {
|
|
99
114
|
// The arena is resolved against us; the drag belongs elsewhere.
|
|
@@ -102,14 +117,18 @@ export function createPan(options: PanOptions) {
|
|
|
102
117
|
return
|
|
103
118
|
}
|
|
104
119
|
if (active === e.pointerId && origin) {
|
|
120
|
+
tracker.push(e.parentX, e.parentY)
|
|
105
121
|
options.onPanMove?.(e.parentX - origin.x, e.parentY - origin.y)
|
|
106
122
|
origin = { x: e.parentX, y: e.parentY }
|
|
107
123
|
}
|
|
108
124
|
},
|
|
109
125
|
onPointerUp: (e: PointerEvent) => {
|
|
110
126
|
if (active === e.pointerId) {
|
|
127
|
+
// The up is not a sample: it sits where the last move left the
|
|
128
|
+
// pointer, and its age is what tells a rest from a flick.
|
|
129
|
+
let velocity = flingVelocity(tracker.velocity())
|
|
111
130
|
reset()
|
|
112
|
-
options.onPanEnd?.()
|
|
131
|
+
options.onPanEnd?.(velocity)
|
|
113
132
|
} else if (armed === e.pointerId) {
|
|
114
133
|
reset()
|
|
115
134
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
// createErrorBoundary comes from signals, not solid-js: solid-js marks it
|
|
2
|
+
// @internal (an app writes <Errored>, a renderer reaches for the primitive)
|
|
3
|
+
// and strips it from its .d.ts, so the solid-js re-export types as any.
|
|
4
|
+
import { createErrorBoundary, createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
|
|
2
5
|
import { createRenderer } from "@solidjs/universal"
|
|
3
|
-
import { createErrorBoundary } from "solid-js"
|
|
4
6
|
import type { Element } from "solid-js"
|
|
5
7
|
import * as tree from "flux:rendertree"
|
|
6
8
|
import { attachWindow, setWindowRoot } from "./window"
|
|
@@ -116,6 +118,10 @@ function removeNode(parent: ProxyNode, node: ProxyNode): void {
|
|
|
116
118
|
const SENTINEL_INTERVAL_MS = 5000
|
|
117
119
|
let sentinelDue = 0
|
|
118
120
|
let warnedLeakTypes = new Set<string>()
|
|
121
|
+
// The orphan total's order of magnitude (floor of log10) at the last
|
|
122
|
+
// warning: a leak that keeps growing at a stable set of types warns again
|
|
123
|
+
// each time the total crosses the next power of ten.
|
|
124
|
+
let warnedMagnitude = -1
|
|
119
125
|
|
|
120
126
|
export function scanForOrphans(now: number): void {
|
|
121
127
|
if (!import.meta.env.DEV) return
|
|
@@ -129,18 +135,22 @@ export function scanForOrphans(now: number): void {
|
|
|
129
135
|
counts.set(node.elementType, (counts.get(node.elementType) ?? 0) + 1)
|
|
130
136
|
}
|
|
131
137
|
if (total === 0) return
|
|
132
|
-
// Warn
|
|
133
|
-
//
|
|
138
|
+
// Warn when a new element type joins the orphans or the total crosses the
|
|
139
|
+
// next order of magnitude, but list every type with its count so the
|
|
140
|
+
// breakdown always adds up to the total.
|
|
134
141
|
let fresh = [...counts].filter(([type]) => !warnedLeakTypes.has(type))
|
|
135
|
-
|
|
142
|
+
let magnitude = Math.floor(Math.log10(total))
|
|
143
|
+
if (fresh.length === 0 && magnitude <= warnedMagnitude) return
|
|
136
144
|
for (let [type] of fresh) warnedLeakTypes.add(type)
|
|
145
|
+
warnedMagnitude = magnitude
|
|
137
146
|
let list = [...counts].map(([type, n]) => `<${type}> x${n}`).join(", ")
|
|
138
147
|
console.warn(
|
|
139
148
|
`Leak sentinel: ${total} nodes are unreachable and will never be freed: ${list}. ` +
|
|
140
149
|
`The usual cause is reading an element-valued prop more than once (every read ` +
|
|
141
150
|
`builds a new subtree); read it once where it mounts, or resolve it with ` +
|
|
142
151
|
`children(). If these nodes are intentionally kept for later mounting, ignore ` +
|
|
143
|
-
`this. The next warning comes when a new element type joins the list
|
|
152
|
+
`this. The next warning comes when a new element type joins the list or the ` +
|
|
153
|
+
`total passes ${10 ** (magnitude + 1)}.`,
|
|
144
154
|
)
|
|
145
155
|
}
|
|
146
156
|
|
package/src/runtime-modules.d.ts
CHANGED
|
@@ -51,6 +51,10 @@ declare module "*.glb" {
|
|
|
51
51
|
const bytes: Uint8Array
|
|
52
52
|
export default bytes
|
|
53
53
|
}
|
|
54
|
+
declare module "*.srtm" {
|
|
55
|
+
const bytes: Uint8Array
|
|
56
|
+
export default bytes
|
|
57
|
+
}
|
|
54
58
|
|
|
55
59
|
// UI event bus (lattice), provided by the runtime as a builtin module.
|
|
56
60
|
// on/once return an unsubscribe function. Notable events: the routed pointer
|
package/src/svg.ts
CHANGED
|
@@ -27,6 +27,7 @@ export type SvgDraw = {
|
|
|
27
27
|
color: string | Gradient
|
|
28
28
|
drawStyle: "fill" | "stroke"
|
|
29
29
|
fillRule?: "nonzero" | "evenodd"
|
|
30
|
+
/** Absent when the source omits it: the d-path default of 1 applies, SVG's own. */
|
|
30
31
|
strokeWidth?: number
|
|
31
32
|
strokeCap?: StrokeCap
|
|
32
33
|
strokeJoin?: StrokeJoin
|
package/src/swipe.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The swipe recognizer: a one-pointer drag that, at the lift, has
|
|
2
|
+
// travelled SWIPE_MIN_DISTANCE from its down at SWIPE_MIN_VELOCITY or
|
|
3
|
+
// more, within SWIPE_ANGLE_TOLERANCE of one of the four axes. Discrete:
|
|
4
|
+
// onSwipe fires once, at the lift, with the direction and the velocity.
|
|
5
|
+
// Direction is the dominant axis of the VELOCITY, not of the travel (a
|
|
6
|
+
// finger that wandered then flicked swipes where it flicked). A drag
|
|
7
|
+
// that ends without qualifying gets onSwipeEnd alone, so a consumer that
|
|
8
|
+
// followed the finger snaps back.
|
|
9
|
+
//
|
|
10
|
+
// Built on createPan with the pan's axis derived from the allowed
|
|
11
|
+
// directions: a horizontal-only swipe inside a vertical ScrollView takes
|
|
12
|
+
// only horizontal drags, exactly as nested scrollers share drags today,
|
|
13
|
+
// and it steals at the pan's slop, so a pressable under it retracts. The
|
|
14
|
+
// pan measures the parent-frame velocity it hands the consumer; the
|
|
15
|
+
// thresholds are finger travel, so the swipe keeps a second tracker in
|
|
16
|
+
// window pixels over the events it wraps. The classifier is shared with
|
|
17
|
+
// the pointer feed's swipe sources. Mouse and touch alike.
|
|
18
|
+
|
|
19
|
+
import { createPan } from "./pan"
|
|
20
|
+
import type { PointerEvent } from "./types"
|
|
21
|
+
import { createVelocityTracker } from "./velocity"
|
|
22
|
+
import type { Velocity } from "./velocity"
|
|
23
|
+
|
|
24
|
+
export type SwipeDirection = "Left" | "Right" | "Up" | "Down"
|
|
25
|
+
|
|
26
|
+
export const SWIPE_DIRECTIONS: readonly SwipeDirection[] = ["Left", "Right", "Up", "Down"]
|
|
27
|
+
|
|
28
|
+
// Least finger travel (window px) from the down to the lift.
|
|
29
|
+
const SWIPE_MIN_DISTANCE = 24
|
|
30
|
+
// Least finger speed at the lift (window px/s): Android's usual
|
|
31
|
+
// minimumFlingVelocity of 50 dp/s at the density it assumes.
|
|
32
|
+
const SWIPE_MIN_VELOCITY = 300
|
|
33
|
+
// How far off an axis (degrees) the velocity may point.
|
|
34
|
+
const SWIPE_ANGLE_TOLERANCE = 30
|
|
35
|
+
const OFF_AXIS_RATIO = Math.tan((SWIPE_ANGLE_TOLERANCE * Math.PI) / 180)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The direction a lift swipes in, or null: `finger` is the velocity in
|
|
39
|
+
* window px/s, `travel` the window-px offset from the down.
|
|
40
|
+
*/
|
|
41
|
+
export function classifySwipe(finger: Velocity, travel: { dx: number; dy: number }, directions: readonly SwipeDirection[] = SWIPE_DIRECTIONS): SwipeDirection | null {
|
|
42
|
+
if (Math.hypot(travel.dx, travel.dy) < SWIPE_MIN_DISTANCE) return null
|
|
43
|
+
let ax = Math.abs(finger.vx)
|
|
44
|
+
let ay = Math.abs(finger.vy)
|
|
45
|
+
if (Math.hypot(ax, ay) < SWIPE_MIN_VELOCITY) return null
|
|
46
|
+
let direction: SwipeDirection
|
|
47
|
+
if (ax >= ay) {
|
|
48
|
+
if (ay > ax * OFF_AXIS_RATIO) return null
|
|
49
|
+
direction = finger.vx < 0 ? "Left" : "Right"
|
|
50
|
+
} else {
|
|
51
|
+
if (ax > ay * OFF_AXIS_RATIO) return null
|
|
52
|
+
direction = finger.vy < 0 ? "Up" : "Down"
|
|
53
|
+
}
|
|
54
|
+
return directions.includes(direction) ? direction : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SwipeOptions {
|
|
58
|
+
/** Which directions count; default all four. Decides the pan's axis:
|
|
59
|
+
* only Left/Right takes horizontal drags, only Up/Down vertical ones. */
|
|
60
|
+
directions?: SwipeDirection[]
|
|
61
|
+
onSwipeStart?: () => void
|
|
62
|
+
/** Movement since the previous event in the handler node's parent frame,
|
|
63
|
+
* so a card follows the finger (createPan's frame rule). */
|
|
64
|
+
onSwipeMove?: (dx: number, dy: number) => void
|
|
65
|
+
/** The lift qualified: fired once, before onSwipeEnd. `velocity` is the
|
|
66
|
+
* pan's, parent-frame px/s. */
|
|
67
|
+
onSwipe?: (direction: SwipeDirection, velocity: Velocity) => void
|
|
68
|
+
/** Every lift of an active swipe, qualified or not. */
|
|
69
|
+
onSwipeEnd?: (velocity: Velocity) => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createSwipe(options: SwipeOptions) {
|
|
73
|
+
let directions = () => options.directions ?? SWIPE_DIRECTIONS
|
|
74
|
+
let axisOf = (): "horizontal" | "vertical" | "both" => {
|
|
75
|
+
let d = directions()
|
|
76
|
+
let h = d.includes("Left") || d.includes("Right")
|
|
77
|
+
let v = d.includes("Up") || d.includes("Down")
|
|
78
|
+
return h && !v ? "horizontal" : v && !h ? "vertical" : "both"
|
|
79
|
+
}
|
|
80
|
+
// The down's window position and the finger's window-px velocity, over
|
|
81
|
+
// every event the wrapped pan sees.
|
|
82
|
+
let down: { id: number; x: number; y: number } | null = null
|
|
83
|
+
// The lift's window position, set just before the pan's up runs.
|
|
84
|
+
let lift: { x: number; y: number } | null = null
|
|
85
|
+
let finger = createVelocityTracker()
|
|
86
|
+
let pan = createPan({
|
|
87
|
+
get axis() {
|
|
88
|
+
return axisOf()
|
|
89
|
+
},
|
|
90
|
+
onPanStart: () => options.onSwipeStart?.(),
|
|
91
|
+
onPanMove: (dx, dy) => options.onSwipeMove?.(dx, dy),
|
|
92
|
+
onPanEnd: velocity => {
|
|
93
|
+
let direction: SwipeDirection | null = null
|
|
94
|
+
if (down && lift) {
|
|
95
|
+
direction = classifySwipe(finger.velocity(), { dx: lift.x - down.x, dy: lift.y - down.y }, directions())
|
|
96
|
+
}
|
|
97
|
+
if (direction) options.onSwipe?.(direction, velocity)
|
|
98
|
+
options.onSwipeEnd?.(velocity)
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
let handlers = {
|
|
103
|
+
onPointerDown: (e: PointerEvent) => {
|
|
104
|
+
if (down === null && (e.button == null || e.button === 0)) {
|
|
105
|
+
down = { id: e.pointerId, x: e.clientX, y: e.clientY }
|
|
106
|
+
finger.reset()
|
|
107
|
+
finger.push(e.clientX, e.clientY)
|
|
108
|
+
}
|
|
109
|
+
pan.handlers.onPointerDown(e)
|
|
110
|
+
},
|
|
111
|
+
onPointerMove: (e: PointerEvent) => {
|
|
112
|
+
if (down && down.id === e.pointerId) finger.push(e.clientX, e.clientY)
|
|
113
|
+
pan.handlers.onPointerMove(e)
|
|
114
|
+
},
|
|
115
|
+
onPointerUp: (e: PointerEvent) => {
|
|
116
|
+
if (down && down.id === e.pointerId) lift = { x: e.clientX, y: e.clientY }
|
|
117
|
+
pan.handlers.onPointerUp(e)
|
|
118
|
+
if (down && down.id === e.pointerId) {
|
|
119
|
+
down = null
|
|
120
|
+
lift = null
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
let cancel = () => {
|
|
125
|
+
pan.cancel()
|
|
126
|
+
down = null
|
|
127
|
+
lift = null
|
|
128
|
+
}
|
|
129
|
+
return { handlers, cancel }
|
|
130
|
+
}
|
package/src/text-input.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// are policy and belong to the component (the "skin") that composes these.
|
|
6
6
|
|
|
7
7
|
import { createMemo, createSignal, flush, untrack } from "@solidjs/signals"
|
|
8
|
-
import {
|
|
8
|
+
import { getLayoutBox, layoutNextLine, measureText, prepareText, unitInk } from "./core"
|
|
9
9
|
import type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
|
|
10
10
|
import { onLayout } from "./window"
|
|
11
11
|
|
|
@@ -247,50 +247,82 @@ export type TextEditorLayout = {
|
|
|
247
247
|
*
|
|
248
248
|
* The scroll offsets are retained between frames and only adjusted when the
|
|
249
249
|
* caret would fall outside the visible range, so stationary text does not
|
|
250
|
-
* jump.
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
250
|
+
* jump. They derive from the caret, the lines and the viewport size, so a
|
|
251
|
+
* frame that changes none of them does no scroll work. The viewport size is
|
|
252
|
+
* the node's layout box (the untransformed solved box: the lines are drawn in
|
|
253
|
+
* the node's own frame, so an ancestor's scale must not change the wrap
|
|
254
|
+
* width), read in onLayout; the post-layout flush drains the update before
|
|
255
|
+
* paint, so lines and scroll track a caret, text or size change in the same
|
|
256
|
+
* frame. A size change that re-breaks nothing (an empty field, lines that
|
|
257
|
+
* still fit, a height-only change) stops at the line placement: the lines,
|
|
258
|
+
* the caret and everything the caller derives from them keep their values
|
|
259
|
+
* and re-run nothing, so a field inside a resizing box costs one placement
|
|
260
|
+
* per frame, not a flush of its whole graph. Pure geometry: no caret
|
|
261
|
+
* rendering and no placeholder/visual policy.
|
|
254
262
|
*/
|
|
255
263
|
export function createTextEditorLayout(
|
|
256
264
|
viewport: () => { id: number } | undefined,
|
|
257
265
|
input: () => TextEditorLayoutInput,
|
|
258
266
|
): TextEditorLayout {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
let [
|
|
267
|
+
// Width and height apart: wrapping depends on the width alone, so a
|
|
268
|
+
// height-only change (a field growing with its content) re-breaks nothing.
|
|
269
|
+
let [viewportWidth, setViewportWidth] = createSignal(0)
|
|
270
|
+
let [viewportHeight, setViewportHeight] = createSignal(0)
|
|
271
|
+
|
|
272
|
+
// The font as a value: input() builds a fresh options object per read, so
|
|
273
|
+
// field equality is what lets the metrics below key on the font itself.
|
|
274
|
+
let font = createMemo(() => input().font, { equals: sameOptions })
|
|
275
|
+
|
|
276
|
+
// One space in the font: its height sits the synthesized blank line, its
|
|
277
|
+
// width is the selection's break sliver. Measured once per font, not once
|
|
278
|
+
// per placement.
|
|
279
|
+
let space = createMemo(() => measureText(" ", font()))
|
|
262
280
|
|
|
263
281
|
let prepared = createMemo(() => {
|
|
264
|
-
let { text,
|
|
265
|
-
return prepareText(text, { ...font, runs, carets: true })
|
|
282
|
+
let { text, runs } = input()
|
|
283
|
+
return prepareText(text, { ...font(), runs, carets: true })
|
|
266
284
|
})
|
|
267
285
|
|
|
268
286
|
// Lines carry their unit range so the caret math walks only their units.
|
|
287
|
+
// Equal placements (same units, same breaks and metrics) keep the previous
|
|
288
|
+
// value, so nothing downstream re-runs for a width change that changed
|
|
289
|
+
// no line. Within a changed placement, a line equal to its predecessor
|
|
290
|
+
// keeps the previous object: a row bound to it (a non-keyed <For> item)
|
|
291
|
+
// sees no change and writes nothing, so a moved break costs the lines it
|
|
292
|
+
// touched, not every line of the field.
|
|
269
293
|
type PlacedLine = EditorLine & { from: number; to: number }
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
+
type Placement = { units: TextUnit[]; lines: PlacedLine[] }
|
|
295
|
+
let placed = createMemo(
|
|
296
|
+
(prev): Placement => {
|
|
297
|
+
let { text, wrap, caretWidth = 0 } = input()
|
|
298
|
+
// Wrapped lines leave room for the caret at the end of a full line, so a
|
|
299
|
+
// wrapping editor never scrolls horizontally.
|
|
300
|
+
let width = wrap ? Math.max(0, viewportWidth() - caretWidth) : Infinity
|
|
301
|
+
let units = wrap ? splitWide(prepared(), width) : prepared()
|
|
302
|
+
let out: PlacedLine[] = []
|
|
303
|
+
let y = 0
|
|
304
|
+
let cursor = 0
|
|
305
|
+
let line = layoutNextLine(units, cursor, width)
|
|
306
|
+
let hardBreak = false
|
|
307
|
+
while (line) {
|
|
308
|
+
out.push({ start: line.start, end: line.end, y, height: line.height, width: line.width, from: line.from, to: line.to })
|
|
309
|
+
y += line.height
|
|
310
|
+
hardBreak = line.hardBreak
|
|
311
|
+
line = layoutNextLine(units, line.cursor, width)
|
|
312
|
+
}
|
|
313
|
+
if (out.length === 0 || hardBreak) {
|
|
314
|
+
let n = units.units.length
|
|
315
|
+
out.push({ start: text.length, end: text.length, y, height: space().height, width: 0, from: n, to: n })
|
|
316
|
+
}
|
|
317
|
+
if (prev && prev.units === units.units) {
|
|
318
|
+
for (let i = 0; i < out.length && i < prev.lines.length; i++) {
|
|
319
|
+
if (sameLine(prev.lines[i]!, out[i]!)) out[i] = prev.lines[i]!
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { units: units.units, lines: out }
|
|
323
|
+
},
|
|
324
|
+
{ equals: samePlacement },
|
|
325
|
+
)
|
|
294
326
|
let lines = createMemo((): EditorLine[] => placed().lines)
|
|
295
327
|
|
|
296
328
|
// The caret stops of a line, left to right, with the pen advanced per unit;
|
|
@@ -375,7 +407,7 @@ export function createTextEditorLayout(
|
|
|
375
407
|
let ls = lines()
|
|
376
408
|
let first = lineOf(start)
|
|
377
409
|
let last = lineOf(end)
|
|
378
|
-
let breakWidth =
|
|
410
|
+
let breakWidth = space().width
|
|
379
411
|
let out: SelectionRect[] = []
|
|
380
412
|
for (let i = first; i <= last; i++) {
|
|
381
413
|
let line = ls[i]!
|
|
@@ -416,22 +448,29 @@ export function createTextEditorLayout(
|
|
|
416
448
|
return 0
|
|
417
449
|
}
|
|
418
450
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
setViewportSize({ width: box?.width ?? 0, height: box?.height ?? 0 })
|
|
424
|
-
flush()
|
|
425
|
-
let { width: vw, height: vh } = viewportSize()
|
|
451
|
+
// Each offset follows from its own previous value: `follow` moves it only
|
|
452
|
+
// when the caret has left the visible range, so the memo carries the
|
|
453
|
+
// retained position across recomputes.
|
|
454
|
+
let scrollX = createMemo((prev: number | undefined): number => {
|
|
426
455
|
let { caretWidth = 0, wrap } = input()
|
|
456
|
+
if (wrap) return 0
|
|
457
|
+
let contentWidth = lines().reduce((w, l) => Math.max(w, l.width), 0)
|
|
458
|
+
let c = caret()
|
|
459
|
+
return follow(prev ?? 0, c.x, caretWidth, viewportWidth(), contentWidth + caretWidth)
|
|
460
|
+
})
|
|
461
|
+
let scrollY = createMemo((prev: number | undefined): number => {
|
|
427
462
|
let ls = lines()
|
|
428
|
-
let contentWidth = ls.reduce((w, l) => Math.max(w, l.width), 0)
|
|
429
463
|
let last = ls[ls.length - 1]!
|
|
430
|
-
let contentHeight = last.y + last.height
|
|
431
464
|
let c = caret()
|
|
465
|
+
return follow(prev ?? 0, c.y, c.height, viewportHeight(), last.y + last.height)
|
|
466
|
+
})
|
|
432
467
|
|
|
433
|
-
|
|
434
|
-
|
|
468
|
+
onLayout(() => {
|
|
469
|
+
let node = viewport()
|
|
470
|
+
if (!node) return
|
|
471
|
+
let box = getLayoutBox(node)
|
|
472
|
+
setViewportWidth(box?.width ?? 0)
|
|
473
|
+
setViewportHeight(box?.height ?? 0)
|
|
435
474
|
})
|
|
436
475
|
|
|
437
476
|
return { lines, caret, caretLine, offsetAtX, selectionRects, lineAtY, step, scrollX, scrollY }
|
|
@@ -480,6 +519,25 @@ function splitWide(prepared: PreparedText, width: number): PreparedText {
|
|
|
480
519
|
return { text: prepared.text, units }
|
|
481
520
|
}
|
|
482
521
|
|
|
522
|
+
// Font options compared field by field: the same keys with the same values.
|
|
523
|
+
function sameOptions(a: MeasureTextOptions, b: MeasureTextOptions): boolean {
|
|
524
|
+
let ka = Object.keys(a) as (keyof MeasureTextOptions)[]
|
|
525
|
+
let kb = Object.keys(b) as (keyof MeasureTextOptions)[]
|
|
526
|
+
return ka.length === kb.length && ka.every((k) => a[k] === b[k])
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// The same break (text range) at the same place with the same metrics.
|
|
530
|
+
function sameLine(a: EditorLine, b: EditorLine): boolean {
|
|
531
|
+
return a.start === b.start && a.end === b.end && a.y === b.y && a.height === b.height && a.width === b.width
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Two placements of the same units with the same lines, break for break and
|
|
535
|
+
// metric for metric.
|
|
536
|
+
function samePlacement(a: { units: TextUnit[]; lines: EditorLine[] }, b: { units: TextUnit[]; lines: EditorLine[] }): boolean {
|
|
537
|
+
if (a.units !== b.units || a.lines.length !== b.lines.length) return false
|
|
538
|
+
return a.lines.every((l, i) => sameLine(l, b.lines[i]!))
|
|
539
|
+
}
|
|
540
|
+
|
|
483
541
|
// The scroll offset along one axis that keeps [pos, pos + size] within a
|
|
484
542
|
// viewport of `extent`, moved only when it is out of view and clamped to the
|
|
485
543
|
// content.
|