@solidrt/core 0.0.13 → 0.0.14
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 +19 -1
- package/package.json +6 -5
- package/src/gpu.ts +7 -0
- package/src/index.ts +51 -0
- package/src/renderer.ts +59 -25
- package/src/scroll.ts +93 -0
- package/src/types.d.ts +13 -5
package/AGENTS.md
CHANGED
|
@@ -29,7 +29,7 @@ tsconfig.json - the two load-bearing lines are jsx + jsxImportSource:
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
32
|
-
2.0.0-beta.
|
|
32
|
+
2.0.0-beta.15); bun resolves them from peerDependencies.
|
|
33
33
|
|
|
34
34
|
## Element model (the parts that are easy to get wrong)
|
|
35
35
|
|
|
@@ -63,6 +63,24 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
|
63
63
|
directly-positioned, often-animating elements (e.g. hundreds of balls), `d-`
|
|
64
64
|
skips the per-element layout that plain elements would incur.
|
|
65
65
|
|
|
66
|
+
- Layout-affecting vs not (this matters for per-frame work). Props fall in three
|
|
67
|
+
buckets, split by where they take effect:
|
|
68
|
+
- `LayoutProps` - width/height, min/max sizes, margin, padding, `position` and
|
|
69
|
+
its `top`/`right`/`bottom`/`left` offsets, flex*/gap/display, grid*,
|
|
70
|
+
aspectRatio, overflow. Changing ANY of these triggers a Taffy reflow of the
|
|
71
|
+
node and its subtree.
|
|
72
|
+
- `TransformProps` - x, y, scale/scaleX/scaleY, rotate/rotateX/rotateY,
|
|
73
|
+
perspective, cx/cy, scrollX/scrollY. Applied at paint/composite; NO reflow.
|
|
74
|
+
- `PaintProps` - color, drawStyle, strokeWidth, blendMode, radius. Also no
|
|
75
|
+
reflow.
|
|
76
|
+
So to MOVE or animate an element - dragging, transitions, per-frame motion -
|
|
77
|
+
translate it with the transform `x`/`y` (or scale/rotate), never by animating
|
|
78
|
+
`left`/`top`/`margin`/`width`. Common trap: `left`/`top` read like "position"
|
|
79
|
+
but they are LAYOUT offsets (for `position:absolute`), so driving them every
|
|
80
|
+
frame reflows the tree. Anchor the element once with layout (e.g.
|
|
81
|
+
`position:absolute` at `left:0,top:0`, or just let normal flow place it) and
|
|
82
|
+
then translate it with `x`/`y`.
|
|
83
|
+
|
|
66
84
|
- Events: there is NO `onClick`/`onPress`. A "button" is a `<view>`/`<rect>`
|
|
67
85
|
with `onPointerDown`. Handlers: onPointerDown/Up/Move/Enter/Leave, onWheel,
|
|
68
86
|
onKeyDown/Up, onTextInput, onFocus/onBlur. Text entry: focus a node with an
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"./gpu": "./src/gpu.ts",
|
|
12
12
|
"./image": "./src/image.ts",
|
|
13
13
|
"./microphone": "./src/microphone.ts",
|
|
14
|
+
"./scroll": "./src/scroll.ts",
|
|
14
15
|
"./speech-recognition": "./src/speech-recognition.ts",
|
|
15
16
|
"./text-input": "./src/text-input.ts",
|
|
16
17
|
"./jsx-runtime": "./jsx-runtime.d.ts",
|
|
@@ -25,11 +26,11 @@
|
|
|
25
26
|
"colord": "^2.9.3"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
|
-
"@solidrt/flux-types": "0.0.
|
|
29
|
+
"@solidrt/flux-types": "0.0.0"
|
|
29
30
|
},
|
|
30
31
|
"peerDependencies": {
|
|
31
|
-
"@solidjs/signals": "2.0.0-beta.
|
|
32
|
-
"@solidjs/universal": "2.0.0-beta.
|
|
33
|
-
"solid-js": "2.0.0-beta.
|
|
32
|
+
"@solidjs/signals": "2.0.0-beta.15",
|
|
33
|
+
"@solidjs/universal": "2.0.0-beta.15",
|
|
34
|
+
"solid-js": "2.0.0-beta.15"
|
|
34
35
|
}
|
|
35
36
|
}
|
package/src/gpu.ts
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
import { getOwner, onCleanup } from "@solidjs/signals"
|
|
7
7
|
import * as gpu from "flux:gpu"
|
|
8
8
|
|
|
9
|
+
// The imperative destroy, surfaced here for the manual-cleanup path documented
|
|
10
|
+
// on the create* helpers (textures made outside a reactive scope, e.g. after an
|
|
11
|
+
// await, are not auto-freed). Re-exported so callers that depend on
|
|
12
|
+
// @solidrt/core -- like @solidrt/components -- can free textures without
|
|
13
|
+
// importing flux directly.
|
|
14
|
+
export { destroyTexture } from "flux:gpu"
|
|
15
|
+
|
|
9
16
|
/**
|
|
10
17
|
* Uploads raw RGBA8 pixels to an immutable GPU texture and returns its id (use
|
|
11
18
|
* it as `<texture src={id} />`). `data` must be exactly `width * height * 4`
|
package/src/index.ts
CHANGED
|
@@ -29,3 +29,54 @@ export type {
|
|
|
29
29
|
Color,
|
|
30
30
|
} from "./types"
|
|
31
31
|
export type { MeasureTextOptions } from "flux:rendertree"
|
|
32
|
+
|
|
33
|
+
// --- Authoring-surface re-exports -------------------------------------------
|
|
34
|
+
// A SolidRT app is built from three substrate packages: @solidjs/signals
|
|
35
|
+
// (reactivity), solid-js (control-flow components), and @solidjs/universal (the
|
|
36
|
+
// renderer factory, surfaced via ./renderer). Forwarding the app-facing pieces
|
|
37
|
+
// here means an app imports its whole vocabulary from "@solidrt/core" instead of
|
|
38
|
+
// having to know which substrate package each symbol lives in. These are already
|
|
39
|
+
// peerDependencies, so this adds no new dependency. Curated on purpose - do not
|
|
40
|
+
// `export *` from solid-js, which would leak DOM/hydration-only helpers that are
|
|
41
|
+
// meaningless on the flux runtime.
|
|
42
|
+
|
|
43
|
+
// Reactivity (from @solidjs/signals).
|
|
44
|
+
export {
|
|
45
|
+
createSignal,
|
|
46
|
+
createMemo,
|
|
47
|
+
createEffect,
|
|
48
|
+
createRenderEffect,
|
|
49
|
+
createRoot,
|
|
50
|
+
createStore,
|
|
51
|
+
reconcile,
|
|
52
|
+
mapArray,
|
|
53
|
+
repeat,
|
|
54
|
+
untrack,
|
|
55
|
+
onCleanup,
|
|
56
|
+
onSettled,
|
|
57
|
+
} from "@solidjs/signals"
|
|
58
|
+
export type { Accessor, Setter, Signal, Store, StoreSetter } from "@solidjs/signals"
|
|
59
|
+
|
|
60
|
+
// Control flow, components, and context (from solid-js).
|
|
61
|
+
export {
|
|
62
|
+
For,
|
|
63
|
+
Show,
|
|
64
|
+
Switch,
|
|
65
|
+
Match,
|
|
66
|
+
Repeat,
|
|
67
|
+
Loading,
|
|
68
|
+
Errored,
|
|
69
|
+
Reveal,
|
|
70
|
+
lazy,
|
|
71
|
+
createUniqueId,
|
|
72
|
+
createContext,
|
|
73
|
+
useContext,
|
|
74
|
+
children,
|
|
75
|
+
} from "solid-js"
|
|
76
|
+
export type {
|
|
77
|
+
Component,
|
|
78
|
+
ParentComponent,
|
|
79
|
+
FlowComponent,
|
|
80
|
+
VoidComponent,
|
|
81
|
+
ComponentProps,
|
|
82
|
+
} from "solid-js"
|
package/src/renderer.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createRoot } from "@solidjs/signals"
|
|
1
|
+
import { createRoot, onCleanup } from "@solidjs/signals"
|
|
2
2
|
import { createRenderer } from "@solidjs/universal"
|
|
3
3
|
import * as tree from "flux:rendertree"
|
|
4
4
|
import { attachWindow } from "./window"
|
|
@@ -30,6 +30,34 @@ function createProxyNode(elementType: ElementType): ProxyNode {
|
|
|
30
30
|
return node
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Detaches `node` from `parent` and destroys it (and all descendants) on both
|
|
34
|
+
// the JS and native sides. Hoisted out of the renderer config so createPortal
|
|
35
|
+
// can reuse it (createRenderer does not return its removeNode hook).
|
|
36
|
+
function removeNode(parent: ProxyNode, node: ProxyNode): void {
|
|
37
|
+
if (!node || !parent) return
|
|
38
|
+
|
|
39
|
+
// console.debug("[srt] removeNode", parent.id, node.id)
|
|
40
|
+
|
|
41
|
+
// Update JS tree references
|
|
42
|
+
let index = parent.children.indexOf(node)
|
|
43
|
+
if (index !== -1) {
|
|
44
|
+
parent.children.splice(index, 1)
|
|
45
|
+
}
|
|
46
|
+
node.parent = undefined
|
|
47
|
+
|
|
48
|
+
tree.deleteNode(parent.id, node.id)
|
|
49
|
+
|
|
50
|
+
// Recursively clean up node and all descendants. Clear focus before
|
|
51
|
+
// dropping handlers so onBlur still fires for a focused descendant.
|
|
52
|
+
let cleanup = (n: ProxyNode) => {
|
|
53
|
+
for (let child of n.children) cleanup(child)
|
|
54
|
+
if (n.id === getFocusedNodeId()) setFocus(null)
|
|
55
|
+
nodes.delete(n.id)
|
|
56
|
+
cleanupNodeHandlers(n.id)
|
|
57
|
+
}
|
|
58
|
+
cleanup(node)
|
|
59
|
+
}
|
|
60
|
+
|
|
33
61
|
export let {
|
|
34
62
|
effect,
|
|
35
63
|
memo,
|
|
@@ -116,30 +144,7 @@ export let {
|
|
|
116
144
|
}
|
|
117
145
|
},
|
|
118
146
|
|
|
119
|
-
removeNode
|
|
120
|
-
if (!node || !parent) return
|
|
121
|
-
|
|
122
|
-
// console.debug("[srt] removeNode", parent.id, node.id)
|
|
123
|
-
|
|
124
|
-
// Update JS tree references
|
|
125
|
-
let index = parent.children.indexOf(node)
|
|
126
|
-
if (index !== -1) {
|
|
127
|
-
parent.children.splice(index, 1)
|
|
128
|
-
}
|
|
129
|
-
node.parent = undefined
|
|
130
|
-
|
|
131
|
-
tree.deleteNode(parent.id, node.id)
|
|
132
|
-
|
|
133
|
-
// Recursively clean up node and all descendants. Clear focus before
|
|
134
|
-
// dropping handlers so onBlur still fires for a focused descendant.
|
|
135
|
-
let cleanup = (n: ProxyNode) => {
|
|
136
|
-
for (let child of n.children) cleanup(child)
|
|
137
|
-
if (n.id === getFocusedNodeId()) setFocus(null)
|
|
138
|
-
nodes.delete(n.id)
|
|
139
|
-
cleanupNodeHandlers(n.id)
|
|
140
|
-
}
|
|
141
|
-
cleanup(node)
|
|
142
|
-
},
|
|
147
|
+
removeNode,
|
|
143
148
|
|
|
144
149
|
getParentNode: (node: ProxyNode) => node?.parent,
|
|
145
150
|
getFirstChild: (node: ProxyNode) => node?.children[0],
|
|
@@ -152,6 +157,10 @@ export let {
|
|
|
152
157
|
},
|
|
153
158
|
})
|
|
154
159
|
|
|
160
|
+
// The app's single <window> node, set by render(). Serves as the default mount
|
|
161
|
+
// target for createPortal (single window by design, so one ambient ref).
|
|
162
|
+
let windowRoot: ProxyNode | undefined
|
|
163
|
+
|
|
155
164
|
/**
|
|
156
165
|
* Mounts a SolidRT app. Call once at the top level: `render(() => <App />)`.
|
|
157
166
|
* The element returned by `code` MUST be a `<window>` (it becomes the native
|
|
@@ -164,7 +173,32 @@ export function render(code: () => any) {
|
|
|
164
173
|
if (!root || root.elementType !== "window") {
|
|
165
174
|
throw new Error("render() root must be a <window> element")
|
|
166
175
|
}
|
|
176
|
+
windowRoot = root
|
|
167
177
|
attachWindow(root.id)
|
|
168
178
|
insert(null, root)
|
|
169
179
|
})
|
|
170
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Relocates an already-built node out of its lexical position to `mount` (the
|
|
184
|
+
* window root by default), then removes it again when the surrounding reactive
|
|
185
|
+
* scope disposes. The low-level portal primitive: it moves a single node and
|
|
186
|
+
* nothing more. Conveniences (an overlay layer, centering, a backdrop) belong
|
|
187
|
+
* in higher packages built on top of it.
|
|
188
|
+
*
|
|
189
|
+
* `node` is a concrete node, not an accessor: its own children (including any
|
|
190
|
+
* reactive content) are already wired by the JSX that built it and keep working
|
|
191
|
+
* wherever it is mounted. We only move the root.
|
|
192
|
+
*
|
|
193
|
+
* The default mount is the window's flex root, so a portaled node that is not
|
|
194
|
+
* `position: "absolute"` will take flow space and displace app content. Position
|
|
195
|
+
* the portal root absolutely, or pass a `mount` target that does it for you.
|
|
196
|
+
*/
|
|
197
|
+
export function createPortal(node: ProxyNode, mount?: ProxyNode): void {
|
|
198
|
+
let target = mount ?? windowRoot
|
|
199
|
+
if (!target) {
|
|
200
|
+
throw new Error("createPortal: no mount target (called before render()?)")
|
|
201
|
+
}
|
|
202
|
+
insertNode(target, node)
|
|
203
|
+
onCleanup(() => removeNode(target, node))
|
|
204
|
+
}
|
package/src/scroll.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Headless scroll mechanism. This primitive owns the objective part of a
|
|
2
|
+
// scrollable region -- the offset and its clamping against the measured content
|
|
3
|
+
// and viewport sizes -- and nothing with a UI opinion. Wheel/drag input,
|
|
4
|
+
// momentum, scrollbars and styling are policy and belong to the component (the
|
|
5
|
+
// "skin") that composes this, the same way createCaretScroll backs TextInput.
|
|
6
|
+
|
|
7
|
+
import { createSignal, flush } from "@solidjs/signals"
|
|
8
|
+
import { getBoundingBox } from "./core"
|
|
9
|
+
import { onLayout } from "./window"
|
|
10
|
+
|
|
11
|
+
export type ScrollAxis = "vertical" | "horizontal" | "both"
|
|
12
|
+
|
|
13
|
+
export type ScrollOffset = { x: number; y: number }
|
|
14
|
+
|
|
15
|
+
export type ScrollOptions = {
|
|
16
|
+
/** Which axes can scroll. Locked axes are pinned to 0. Default "vertical". */
|
|
17
|
+
axis?: ScrollAxis
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Scroll = {
|
|
21
|
+
/** Current clamped offset, as a reactive accessor. */
|
|
22
|
+
offset(): ScrollOffset
|
|
23
|
+
/** Scroll by a delta (positive moves content up/left), clamped to range. */
|
|
24
|
+
scrollBy(dx: number, dy: number): void
|
|
25
|
+
/** Scroll to an absolute offset, clamped to range. */
|
|
26
|
+
scrollTo(x: number, y: number): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the scroll offset for a viewport node given its content node. The
|
|
31
|
+
* offset is retained between frames and re-clamped in onLayout against the
|
|
32
|
+
* current content-vs-viewport overflow, so the view stays valid when content
|
|
33
|
+
* grows or shrinks (e.g. an offset that scrolled to the bottom snaps up when the
|
|
34
|
+
* list gets shorter). scrollBy/scrollTo clamp against the most recently measured
|
|
35
|
+
* range. Pure geometry: no input handling and no visual policy.
|
|
36
|
+
*
|
|
37
|
+
* The viewport node is the clipping box (overflow hidden); the content node is
|
|
38
|
+
* the inner wrapper that holds the children and takes their natural size. Apply
|
|
39
|
+
* the returned offset to the viewport's scrollX/scrollY.
|
|
40
|
+
*/
|
|
41
|
+
export function createScroll(
|
|
42
|
+
viewport: () => { id: number } | undefined,
|
|
43
|
+
content: () => { id: number } | undefined,
|
|
44
|
+
options: ScrollOptions = {},
|
|
45
|
+
): Scroll {
|
|
46
|
+
let axis = options.axis ?? "vertical"
|
|
47
|
+
let canX = axis === "horizontal" || axis === "both"
|
|
48
|
+
let canY = axis === "vertical" || axis === "both"
|
|
49
|
+
|
|
50
|
+
let [offset, setOffset] = createSignal<ScrollOffset>({ x: 0, y: 0 })
|
|
51
|
+
|
|
52
|
+
// Last measured overflow, refreshed each layout. scrollBy/scrollTo clamp
|
|
53
|
+
// against these between layouts; onLayout re-clamps once new sizes are known.
|
|
54
|
+
let maxX = 0
|
|
55
|
+
let maxY = 0
|
|
56
|
+
|
|
57
|
+
let clamp = (x: number, y: number): ScrollOffset => ({
|
|
58
|
+
x: canX ? Math.max(0, Math.min(x, maxX)) : 0,
|
|
59
|
+
y: canY ? Math.max(0, Math.min(y, maxY)) : 0,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
let set = (x: number, y: number) => {
|
|
63
|
+
let cur = offset()
|
|
64
|
+
let next = clamp(x, y)
|
|
65
|
+
if (next.x !== cur.x || next.y !== cur.y) setOffset(next)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
onLayout(() => {
|
|
69
|
+
let vp = viewport()
|
|
70
|
+
let ct = content()
|
|
71
|
+
if (!vp || !ct) return
|
|
72
|
+
let vb = getBoundingBox(vp)
|
|
73
|
+
let cb = getBoundingBox(ct)
|
|
74
|
+
if (!vb || !cb) return
|
|
75
|
+
maxX = Math.max(0, cb.width - vb.width)
|
|
76
|
+
maxY = Math.max(0, cb.height - vb.height)
|
|
77
|
+
let cur = offset()
|
|
78
|
+
let next = clamp(cur.x, cur.y)
|
|
79
|
+
if (next.x !== cur.x || next.y !== cur.y) {
|
|
80
|
+
setOffset(next)
|
|
81
|
+
flush()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
offset,
|
|
87
|
+
scrollBy: (dx, dy) => {
|
|
88
|
+
let cur = offset()
|
|
89
|
+
set(cur.x + dx, cur.y + dy)
|
|
90
|
+
},
|
|
91
|
+
scrollTo: (x, y) => set(x, y),
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/types.d.ts
CHANGED
|
@@ -153,14 +153,22 @@ export interface TransformProps {
|
|
|
153
153
|
scrollY?: number
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// Window-relative pointer coordinates are reported as clientX/clientY (matching
|
|
157
|
+
// the DOM MouseEvent). pointerType distinguishes mouse from touch; button is the
|
|
158
|
+
// pressed button on down/up (0 = primary); the modifier flags mirror the DOM.
|
|
156
159
|
export interface PointerEvent {
|
|
157
|
-
|
|
158
|
-
|
|
160
|
+
clientX: number
|
|
161
|
+
clientY: number
|
|
162
|
+
pointerId: number
|
|
163
|
+
pointerType: "mouse" | "touch" | "pen" | (string & {})
|
|
164
|
+
button?: number
|
|
165
|
+
shiftKey: boolean
|
|
166
|
+
ctrlKey: boolean
|
|
167
|
+
altKey: boolean
|
|
168
|
+
metaKey: boolean
|
|
159
169
|
}
|
|
160
170
|
|
|
161
|
-
export interface WheelEvent {
|
|
162
|
-
x: number
|
|
163
|
-
y: number
|
|
171
|
+
export interface WheelEvent extends PointerEvent {
|
|
164
172
|
deltaX: number
|
|
165
173
|
deltaY: number
|
|
166
174
|
}
|