lecodes-cli 0.7.0 → 0.7.1
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/dist/index.js +3 -3
- package/package.json +3 -3
- package/runtime/sdk/g2/Scene2D.ts +51 -5
- package/runtime/sdk/gl/Model.ts +9 -0
- package/runtime/sdk/gl/Scene.ts +76 -11
- package/runtime/sdk/inject.ts +14 -6
- package/runtime/sdk/plugins/camera.ts +81 -0
- package/runtime/sdk/plugins/geolocation.ts +123 -0
- package/runtime/sdk/plugins/permission.ts +7 -0
- package/runtime/sdk/plugins/qr.ts +61 -24
- package/runtime/sdk/runtime/app.ts +37 -0
- package/runtime/sdk/runtime/appEvents.ts +29 -0
- package/runtime/sdk/runtime/channel.ts +50 -0
- package/runtime/sdk/runtime/clipboard.ts +20 -0
- package/runtime/sdk/runtime/datetime.ts +2 -4
- package/runtime/sdk/runtime/device.ts +137 -4
- package/runtime/sdk/runtime/misc.ts +4 -0
- package/runtime/sdk/runtime/service.ts +82 -0
- package/runtime/sdk/runtime/touch.ts +26 -0
- package/runtime/sdk/ui/NativeView.ts +144 -0
- package/runtime/sdk/ui/UI.ts +5 -1
- package/runtime/sdk/ui/UIButton.ts +26 -3
- package/runtime/sdk/ui/UIInput.ts +2 -2
- package/runtime/sdk/ui/UINode.ts +24 -4
- package/runtime/sdk/ui/UIScreen.ts +63 -29
- package/runtime/sdk/ui/UIScreenHost.ts +3 -2
- package/runtime/sdk/ui/UIText.ts +1 -1
- package/runtime/sdk/ui/UIVideo.ts +48 -2
- package/runtime/sdk/ui/UIWidget.ts +34 -1
- package/runtime/sdk/ui/presentable.ts +116 -0
- package/runtime/sdk/ui/router.ts +73 -29
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/runtime/camera.ts +0 -31
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The app's own lifecycle + how it was opened (docs/app-lifecycle-plan.md). Events are semantic —
|
|
2
|
+
// "pause"/"resume", never Activity/Scene vocabulary — and the current values are synchronous pulls
|
|
3
|
+
// so render-loop code can read them without subscribing.
|
|
4
|
+
|
|
5
|
+
import { appEventsOn, appEventsOff } from "./appEvents"
|
|
6
|
+
|
|
7
|
+
export type AppState = "active" | "background"
|
|
8
|
+
|
|
9
|
+
type AppEventMap = {
|
|
10
|
+
/** The app left the foreground (home button, tab hidden, another app on top). Delivered before
|
|
11
|
+
* the host halts the frame loop — the last chance to persist state / pause work. */
|
|
12
|
+
pause: () => void
|
|
13
|
+
/** The app returned to the foreground; frames are running again. */
|
|
14
|
+
resume: () => void
|
|
15
|
+
/** A link arrived while the app was running (warm deep link). `app.launchUrl` is already
|
|
16
|
+
* updated to the same value when this fires. */
|
|
17
|
+
url: (url: string) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const app = {
|
|
21
|
+
/** Current lifecycle state. `"background"` while the app is not the foreground app / the tab is
|
|
22
|
+
* hidden. `"active"` on hosts that don't track it. */
|
|
23
|
+
get state(): AppState {
|
|
24
|
+
return _creatorUtils.appState?.() === "background" ? "background" : "active"
|
|
25
|
+
},
|
|
26
|
+
/** The URL the app was (most recently) opened with — the deep link — or `null` for a plain
|
|
27
|
+
* launch. Warm links update it and fire the `"url"` event. */
|
|
28
|
+
get launchUrl(): string | null {
|
|
29
|
+
return _creatorUtils.getLaunchUrl?.() ?? null
|
|
30
|
+
},
|
|
31
|
+
addEventListener<E extends keyof AppEventMap>(event: E, callback: AppEventMap[E]): void {
|
|
32
|
+
appEventsOn(event, callback as (data?: string) => void)
|
|
33
|
+
},
|
|
34
|
+
removeEventListener<E extends keyof AppEventMap>(event: E, callback: AppEventMap[E]): void {
|
|
35
|
+
appEventsOff(event, callback as (data?: string) => void)
|
|
36
|
+
},
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The shared app-event dispatcher. The host exposes ONE registerAppEvent slot per JS world
|
|
2
|
+
// (docs/app-lifecycle-plan.md), so every SDK consumer (the `app` global's pause/resume/url, the
|
|
3
|
+
// `device` global's online/offline) subscribes here and this module registers with the host once,
|
|
4
|
+
// lazily on the first subscription — no top-level side effect, tree-shaking drops it when unused.
|
|
5
|
+
|
|
6
|
+
type AppEventCallback = (data?: string) => void
|
|
7
|
+
|
|
8
|
+
const listeners: Record<string, AppEventCallback[]> = {}
|
|
9
|
+
let registered = false
|
|
10
|
+
|
|
11
|
+
const dispatch = (event: string, data?: string): void => {
|
|
12
|
+
const arr = listeners[event]
|
|
13
|
+
if (arr) for (const cb of arr.slice()) cb(data)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const appEventsOn = (event: string, cb: AppEventCallback): void => {
|
|
17
|
+
;(listeners[event] ??= []).push(cb)
|
|
18
|
+
if (!registered) {
|
|
19
|
+
registered = true
|
|
20
|
+
_creatorUtils.registerAppEvent?.(dispatch)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const appEventsOff = (event: string, cb: AppEventCallback): void => {
|
|
25
|
+
const arr = listeners[event]
|
|
26
|
+
if (!arr) return
|
|
27
|
+
const i = arr.indexOf(cb)
|
|
28
|
+
if (i >= 0) arr.splice(i, 1)
|
|
29
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The JSON wire protocol shared by the two host plugin channels — NativeView
|
|
2
|
+
// (`_creatorUI.viewCall`, see ui/NativeView.ts) and headless services
|
|
3
|
+
// (`_creatorUtils.serviceCall`, see runtime/service.ts). One protocol, two transports:
|
|
4
|
+
// `args`, results, and event payloads travel as JSON strings, and an absent/empty string
|
|
5
|
+
// decodes to `undefined`. Typed plugins (QRScanner, CameraView, Geolocation) layer methods
|
|
6
|
+
// over this — see src/plugins/.
|
|
7
|
+
|
|
8
|
+
/** @internal event-name → listeners. NativeView exposes it as `nvl` (host contract). */
|
|
9
|
+
export type ChannelListeners = Record<string, ((data?: any) => void)[]>
|
|
10
|
+
|
|
11
|
+
/** @internal */
|
|
12
|
+
export const _channelOn = (listeners: ChannelListeners, event: string, callback: (data?: any) => void): void => {
|
|
13
|
+
(listeners[event] ??= []).push(callback)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @internal */
|
|
17
|
+
export const _channelOff = (listeners: ChannelListeners, event: string, callback: (data?: any) => void): void => {
|
|
18
|
+
const list = listeners[event]
|
|
19
|
+
if (!list) return
|
|
20
|
+
const i = list.indexOf(callback)
|
|
21
|
+
if (i >= 0) list.splice(i, 1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @internal deliver a host event: decode the JSON payload, fan out (copy-before-iterate). */
|
|
25
|
+
export const _channelEmit = (listeners: ChannelListeners, event: string, dataJson?: string): void => {
|
|
26
|
+
const list = listeners[event]
|
|
27
|
+
if (!list || list.length === 0) return
|
|
28
|
+
const data = dataJson === undefined || dataJson === "" ? undefined : JSON.parse(dataJson)
|
|
29
|
+
for (const cb of [...list]) cb(data)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ChannelCallBridge = (
|
|
33
|
+
id: number, method: string, args: string,
|
|
34
|
+
onComplete: (result?: string) => void, onError: (err: string) => void,
|
|
35
|
+
) => void
|
|
36
|
+
|
|
37
|
+
/** @internal one JSON call over a channel bridge; `bridge` may be absent (optional ABI member). */
|
|
38
|
+
export const _channelCall = (
|
|
39
|
+
bridge: ChannelCallBridge | undefined, unsupportedError: string,
|
|
40
|
+
id: number, method: string, args: any[],
|
|
41
|
+
): Promise<any> =>
|
|
42
|
+
new Promise((res, rej) => {
|
|
43
|
+
if (typeof bridge !== "function") {
|
|
44
|
+
rej(new Error(unsupportedError))
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
bridge(id, method, JSON.stringify(args),
|
|
48
|
+
(result?: string) => res(result === undefined || result === "" ? undefined : JSON.parse(result)),
|
|
49
|
+
(err: string) => rej(new Error(err)))
|
|
50
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// System clipboard (docs/app-lifecycle-plan.md). Write is fire-and-forget; read is async because
|
|
2
|
+
// web gates it behind a permission prompt (native hosts complete synchronously).
|
|
3
|
+
|
|
4
|
+
export const clipboard = {
|
|
5
|
+
/** Put `text` on the system clipboard. Silent no-op on hosts without clipboard access. */
|
|
6
|
+
write(text: string): void {
|
|
7
|
+
_creatorUtils.clipboardWrite?.(text)
|
|
8
|
+
},
|
|
9
|
+
/** Read text from the system clipboard. Rejects when the platform has no clipboard access, the
|
|
10
|
+
* user denied it, or there is nothing readable. */
|
|
11
|
+
read(): Promise<string> {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
if (typeof _creatorUtils.clipboardRead !== "function") {
|
|
14
|
+
reject(new Error("Clipboard is not supported on this platform"))
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
_creatorUtils.clipboardRead(resolve, (err: string) => reject(new Error(err)))
|
|
18
|
+
})
|
|
19
|
+
},
|
|
20
|
+
}
|
|
@@ -243,10 +243,8 @@ export class DateValue {
|
|
|
243
243
|
startOf(unit: Unit): DateValue {
|
|
244
244
|
const d = new Date(this.t)
|
|
245
245
|
switch (unit) {
|
|
246
|
-
case "year": d.setMonth(0)
|
|
247
|
-
|
|
248
|
-
case "month": d.setDate(1)
|
|
249
|
-
// falls through
|
|
246
|
+
case "year": d.setMonth(0); d.setDate(1); d.setHours(0, 0, 0, 0); break
|
|
247
|
+
case "month": d.setDate(1); d.setHours(0, 0, 0, 0); break
|
|
250
248
|
case "day": d.setHours(0, 0, 0, 0); break
|
|
251
249
|
case "hour": d.setMinutes(0, 0, 0); break
|
|
252
250
|
case "minute": d.setSeconds(0, 0); break
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
// Platform info + the host resize event. The resize listener registers with the host lazily on the
|
|
2
2
|
// first subscription (no top-level side effect, so tree-shaking can drop this entirely if unused).
|
|
3
3
|
|
|
4
|
+
import { Quat } from "../math/quat"
|
|
5
|
+
import { Vec3 } from "../math/vec"
|
|
6
|
+
import { appEventsOn, appEventsOff } from "./appEvents"
|
|
7
|
+
|
|
4
8
|
type ResizeCallback = (width: number, height: number) => void
|
|
5
9
|
|
|
10
|
+
type DeviceEventMap = {
|
|
11
|
+
resize: ResizeCallback
|
|
12
|
+
/** Connectivity came back (best-effort, navigator.onLine semantics). */
|
|
13
|
+
online: () => void
|
|
14
|
+
/** Connectivity was lost. */
|
|
15
|
+
offline: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
const resizeListeners: ResizeCallback[] = []
|
|
7
19
|
let resizeRegistered = false
|
|
8
20
|
|
|
@@ -10,6 +22,49 @@ const onResize = (width: number, height: number): void => {
|
|
|
10
22
|
for (const cb of resizeListeners.slice()) cb(width, height)
|
|
11
23
|
}
|
|
12
24
|
|
|
25
|
+
/** Semantic haptic styles for `device.vibrate`. Impact styles (`light`/`medium`/`heavy`/`soft`/
|
|
26
|
+
* `rigid`) are a physical "tap" of varying weight; notification styles (`success`/`warning`/`error`)
|
|
27
|
+
* cue an outcome; `selection` is a light tick for a value change. Chosen to map 1:1 onto iOS
|
|
28
|
+
* `UIFeedbackGenerator` and, on Android, `HapticFeedbackConstants` / `VibrationEffect` — so the same
|
|
29
|
+
* call feels native everywhere, rather than a duration that only web/Android can honor. */
|
|
30
|
+
export type HapticStyle =
|
|
31
|
+
| "light" | "medium" | "heavy" | "soft" | "rigid"
|
|
32
|
+
| "success" | "warning" | "error"
|
|
33
|
+
| "selection"
|
|
34
|
+
|
|
35
|
+
/** Options for `device.motion.start`. */
|
|
36
|
+
export interface MotionOptions {
|
|
37
|
+
/** Sensor update interval in seconds (default 1/60). The sensor fuses at ≥ this rate in the
|
|
38
|
+
* background; you poll the freshest sample each frame, so this is a floor, not a sync. */
|
|
39
|
+
interval?: number
|
|
40
|
+
/** Frame the readables are delivered in. "world" (default): engine Y-up, screen-oriented — drops
|
|
41
|
+
* straight into `camera.quaternion` / any node. "device": the raw sensor frame, no conversion. */
|
|
42
|
+
frame?: "world" | "device"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- device.motion state (pull-per-frame model) ---
|
|
46
|
+
let motionEnabled = false
|
|
47
|
+
let motionFrame: "world" | "device" = "world"
|
|
48
|
+
let motionYawOffset = 0 // radians about world up, captured by recenter()
|
|
49
|
+
|
|
50
|
+
// Calibration constants — VERIFY ON A PHYSICAL DEVICE (the simulator reports no motion).
|
|
51
|
+
// • CM_TO_WORLD: Core Motion's reference frame is Z-up; the engine world is Y-up. If a level horizon
|
|
52
|
+
// reads tilted ~90°, this is the term to check.
|
|
53
|
+
// • ORIENTATION_ROLL: roll about the view axis so camera-up tracks screen-up, indexed by the native
|
|
54
|
+
// interface-orientation code [portrait, landscapeLeft, landscapeRight, upsideDown]. If landscape is
|
|
55
|
+
// upside-down or rolled the wrong way, flip the ±π/2 signs.
|
|
56
|
+
// If the whole panorama turns the wrong way (look right → view goes left), conjugate `raw` below.
|
|
57
|
+
const CM_TO_WORLD = Quat.fromAxisAngle([1, 0, 0], -Math.PI / 2)
|
|
58
|
+
const ORIENTATION_ROLL = [0, Math.PI / 2, -Math.PI / 2, Math.PI]
|
|
59
|
+
|
|
60
|
+
// Device attitude → engine world frame, with the interface-orientation roll folded in (yaw NOT yet
|
|
61
|
+
// recentered). Shared by `attitude` (which adds the recenter) and `gravity` (which is yaw-invariant).
|
|
62
|
+
const orientedAttitude = (s: ArrayLike<number>): Quat => {
|
|
63
|
+
const raw = new Quat(s[0], s[1], s[2], s[3])
|
|
64
|
+
const roll = Quat.fromAxisAngle([0, 0, 1], ORIENTATION_ROLL[s[7]] ?? 0)
|
|
65
|
+
return CM_TO_WORLD.mul(raw).mul(roll)
|
|
66
|
+
}
|
|
67
|
+
|
|
13
68
|
export const device = {
|
|
14
69
|
get platform(): "web" | "android" | "ios" | string {
|
|
15
70
|
return _creatorUtils.platform
|
|
@@ -43,17 +98,95 @@ export const device = {
|
|
|
43
98
|
setPreciseTouch(enabled: boolean): void {
|
|
44
99
|
_creatorUtils.setPreciseTouch?.(enabled)
|
|
45
100
|
},
|
|
46
|
-
|
|
101
|
+
/** Fire a one-shot haptic of the given semantic `style` (default `"medium"`) — a physical tap on
|
|
102
|
+
* supported hardware (iOS Taptic Engine, Android vibrator). Chosen by meaning, not duration, so it
|
|
103
|
+
* feels native on each platform; see {@link HapticStyle}. Host-gated: a silent no-op where there's
|
|
104
|
+
* no haptic hardware (iPad, older iPhones, web, headless). */
|
|
105
|
+
vibrate(style: HapticStyle = "medium"): void {
|
|
106
|
+
_creatorUtils.vibrate?.(style)
|
|
107
|
+
},
|
|
108
|
+
/** Device-orientation sensor (gyro + accelerometer, fused) for tilt/steering and magic-window /
|
|
109
|
+
* 360° panoramas. Poll `attitude` / `gravity` inside setLoop; they return the freshest fused
|
|
110
|
+
* sample, so the sensor rate need not match your frame rate. Host-gated: a no-op with no sensor. */
|
|
111
|
+
motion: {
|
|
112
|
+
/** Whether this device has the motion sensors at all (no gyro → false; iPad/older, web, headless). */
|
|
113
|
+
get available(): boolean {
|
|
114
|
+
return _creatorUtils.motionAvailable?.() ?? false
|
|
115
|
+
},
|
|
116
|
+
/** Whether updates are currently running (start succeeded and stop hasn't been called). */
|
|
117
|
+
get enabled(): boolean {
|
|
118
|
+
return motionEnabled
|
|
119
|
+
},
|
|
120
|
+
/** Begin sensor updates. Resolves to whether it actually started (false = no sensor / denied).
|
|
121
|
+
* Async so a web host can await its permission prompt; native resolves immediately. */
|
|
122
|
+
async start(options: MotionOptions = {}): Promise<boolean> {
|
|
123
|
+
motionFrame = options.frame ?? "world"
|
|
124
|
+
motionEnabled = !!(await (_creatorUtils.motionStart?.(options.interval ?? 1 / 60) ?? false))
|
|
125
|
+
return motionEnabled
|
|
126
|
+
},
|
|
127
|
+
/** Stop sensor updates and release the sensor (battery). */
|
|
128
|
+
stop(): void {
|
|
129
|
+
_creatorUtils.motionStop?.()
|
|
130
|
+
motionEnabled = false
|
|
131
|
+
},
|
|
132
|
+
/** Capture the current heading as "forward" — a yaw-only recenter (pitch/roll stay gravity-
|
|
133
|
+
* referenced, so the horizon stays level). No-op in the "device" frame. */
|
|
134
|
+
recenter(): void {
|
|
135
|
+
const s = _creatorUtils.getMotionSample?.()
|
|
136
|
+
if (!s || motionFrame === "device") { motionYawOffset = 0; return }
|
|
137
|
+
const f = orientedAttitude(s).rotateVec3([0, 0, -1]) // world-space camera forward
|
|
138
|
+
motionYawOffset = Math.atan2(-f.x, -f.z) // heading about world up
|
|
139
|
+
},
|
|
140
|
+
/** The device's current orientation as a `Quat`. In the "world" frame (default) it's engine Y-up
|
|
141
|
+
* and screen-oriented, so `camera.quaternion = device.motion.attitude` is a complete magic-window
|
|
142
|
+
* / panorama camera. `Quat.identity` until the first sample arrives / when not running. */
|
|
143
|
+
get attitude(): Quat {
|
|
144
|
+
const s = _creatorUtils.getMotionSample?.()
|
|
145
|
+
if (!s) return Quat.identity
|
|
146
|
+
if (motionFrame === "device") return new Quat(s[0], s[1], s[2], s[3])
|
|
147
|
+
const q = orientedAttitude(s)
|
|
148
|
+
return Quat.fromAxisAngle([0, 1, 0], -motionYawOffset).mul(q) // yaw recenter (world-frame)
|
|
149
|
+
},
|
|
150
|
+
/** Gravity direction for tilt controls. In the "world" frame it's SCREEN space (x → right, y →
|
|
151
|
+
* down, matching clientX/clientY), orientation-aware — a 2D game reads `gravity.x / gravity.y`.
|
|
152
|
+
* `(0,0,0)` when not running. */
|
|
153
|
+
get gravity(): Vec3 {
|
|
154
|
+
const s = _creatorUtils.getMotionSample?.()
|
|
155
|
+
if (!s) return new Vec3(0, 0, 0)
|
|
156
|
+
if (motionFrame === "device") return new Vec3(s[4], s[5], s[6])
|
|
157
|
+
// Re-express the raw device-frame gravity in screen space: undo the interface roll (about the
|
|
158
|
+
// screen normal), then flip Y to screen-down. Uses the unambiguous CMDeviceMotion.gravity, so
|
|
159
|
+
// it stays correct regardless of the attitude-quaternion convention above.
|
|
160
|
+
const unroll = Quat.fromAxisAngle([0, 0, 1], -(ORIENTATION_ROLL[s[7]] ?? 0))
|
|
161
|
+
const g = new Vec3(s[4], s[5], s[6]).rotate(unroll)
|
|
162
|
+
return new Vec3(g.x, -g.y, g.z)
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
/** Current connectivity — best-effort navigator.onLine semantics: `false` only when the platform
|
|
166
|
+
* is sure there's no network. `true` on hosts that don't track it. Change events: `"online"` /
|
|
167
|
+
* `"offline"`. */
|
|
168
|
+
get online(): boolean {
|
|
169
|
+
return _creatorUtils.isOnline?.() ?? true
|
|
170
|
+
},
|
|
171
|
+
addEventListener<E extends keyof DeviceEventMap>(channel: E, callback: DeviceEventMap[E]): void {
|
|
172
|
+
if (channel === "online" || channel === "offline") {
|
|
173
|
+
appEventsOn(channel, callback as () => void)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
47
176
|
if (channel !== "resize") return
|
|
48
|
-
resizeListeners.push(callback)
|
|
177
|
+
resizeListeners.push(callback as ResizeCallback)
|
|
49
178
|
if (!resizeRegistered) {
|
|
50
179
|
resizeRegistered = true
|
|
51
180
|
_creatorUI.registerResizeEvent(onResize)
|
|
52
181
|
}
|
|
53
182
|
},
|
|
54
|
-
removeEventListener(channel:
|
|
183
|
+
removeEventListener<E extends keyof DeviceEventMap>(channel: E, callback: DeviceEventMap[E]): void {
|
|
184
|
+
if (channel === "online" || channel === "offline") {
|
|
185
|
+
appEventsOff(channel, callback as () => void)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
55
188
|
if (channel !== "resize") return
|
|
56
|
-
const i = resizeListeners.indexOf(callback)
|
|
189
|
+
const i = resizeListeners.indexOf(callback as ResizeCallback)
|
|
57
190
|
if (i >= 0) resizeListeners.splice(i, 1)
|
|
58
191
|
},
|
|
59
192
|
}
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
/** Show a transient toast notification. */
|
|
4
4
|
export const toast = (msg: string): void => _creatorUI.toast(msg)
|
|
5
5
|
|
|
6
|
+
/** Open `url` in the system browser / external handler (fire-and-forget, like `share`). Silent
|
|
7
|
+
* no-op on hosts without one (headless). */
|
|
8
|
+
export const openURL = (url: string): void => _creatorUtils.openUrl?.(url)
|
|
9
|
+
|
|
6
10
|
export type SvgSourceValue = {
|
|
7
11
|
readonly svg: string
|
|
8
12
|
tintColor: string | null
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// A host-registered headless service (geolocation, bluetooth, contacts, …) — the UI-less sibling
|
|
2
|
+
// of NativeView (docs/service-channel-plan.md). The host registers a *capability*
|
|
3
|
+
// (`registerService("geolocation") { params, channel -> ... }`); the SDK talks to it over the
|
|
4
|
+
// shared JSON call/event protocol (runtime/channel.ts) via `_creatorUtils.service*`.
|
|
5
|
+
//
|
|
6
|
+
// Not a public global: apps meet services only through typed plugin wrappers (Geolocation —
|
|
7
|
+
// src/plugins/), which subclass or wrap this exactly like QRScanner wraps NativeViewElement.
|
|
8
|
+
|
|
9
|
+
import { _channelCall, _channelEmit, _channelOff, _channelOn, type ChannelListeners } from "./channel"
|
|
10
|
+
|
|
11
|
+
/** @internal Base for typed service plugins. The host session opens lazily on the first `call()`
|
|
12
|
+
* (`on()` only records listeners), and `close()` releases it — a later `call()` reopens a fresh
|
|
13
|
+
* session. Session ids are host-allocated (websocket model): the `onEvent` callback is handed
|
|
14
|
+
* over at open, so no SDK-side ref registry is needed. */
|
|
15
|
+
export class ServiceClient {
|
|
16
|
+
readonly serviceName: string
|
|
17
|
+
readonly params: any
|
|
18
|
+
private _serviceId: number | null = null
|
|
19
|
+
private readonly _listeners: ChannelListeners = {}
|
|
20
|
+
|
|
21
|
+
constructor(serviceName: string, params: any = {}) {
|
|
22
|
+
this.serviceName = serviceName
|
|
23
|
+
this.params = params
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether this host registered a `registerService` factory under `name`. */
|
|
27
|
+
static isSupported(name: string): boolean {
|
|
28
|
+
return typeof _creatorUtils !== "undefined" && typeof _creatorUtils.isServiceSupported === "function"
|
|
29
|
+
&& _creatorUtils.isServiceSupported(name)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @internal open (or reuse) the host session; throws when the host has no channel/factory. */
|
|
33
|
+
private _ensureOpen(): number {
|
|
34
|
+
if (this._serviceId !== null) return this._serviceId
|
|
35
|
+
if (typeof _creatorUtils === "undefined" || typeof _creatorUtils.serviceOpen !== "function") {
|
|
36
|
+
throw new Error(`Service("${this.serviceName}") is not supported on this host (requires _creatorUtils.serviceOpen)`)
|
|
37
|
+
}
|
|
38
|
+
const id = _creatorUtils.serviceOpen(this.serviceName, JSON.stringify(this.params ?? {}),
|
|
39
|
+
(event: string, dataJson?: string) => _channelEmit(this._listeners, event, dataJson))
|
|
40
|
+
if (id < 0) {
|
|
41
|
+
throw new Error(`Service("${this.serviceName}") is not registered on this host`)
|
|
42
|
+
}
|
|
43
|
+
this._serviceId = id
|
|
44
|
+
return id
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Invoke a method on the service (`geo.call("getCurrent", options)`). Args and the result are
|
|
48
|
+
* JSON-serialized. Rejects if the host has no such service/method. */
|
|
49
|
+
call(method: string, ...args: any[]): Promise<any> {
|
|
50
|
+
let id: number
|
|
51
|
+
try {
|
|
52
|
+
id = this._ensureOpen()
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return Promise.reject(e)
|
|
55
|
+
}
|
|
56
|
+
return _channelCall(
|
|
57
|
+
typeof _creatorUtils !== "undefined" ? _creatorUtils.serviceCall : undefined,
|
|
58
|
+
`Service("${this.serviceName}") is not supported on this host (requires _creatorUtils.serviceCall)`,
|
|
59
|
+
id, method, args)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Subscribe to an event the service emits (`geo.on("position", cb)`). */
|
|
63
|
+
on(event: string, callback: (data?: any) => void): this {
|
|
64
|
+
_channelOn(this._listeners, event, callback)
|
|
65
|
+
return this
|
|
66
|
+
}
|
|
67
|
+
off(event: string, callback: (data?: any) => void): this {
|
|
68
|
+
_channelOff(this._listeners, event, callback)
|
|
69
|
+
return this
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Close the host session (stops whatever the service was doing). Listeners stay registered —
|
|
73
|
+
* a later `call()` opens a fresh session that delivers to them again. */
|
|
74
|
+
close(): void {
|
|
75
|
+
if (this._serviceId === null) return
|
|
76
|
+
const id = this._serviceId
|
|
77
|
+
this._serviceId = null
|
|
78
|
+
if (typeof _creatorUtils !== "undefined" && typeof _creatorUtils.serviceClose === "function") {
|
|
79
|
+
_creatorUtils.serviceClose(id)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -48,3 +48,29 @@ export class TouchStartEvent<T = any> {
|
|
|
48
48
|
this._trackHandler = handler
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
/** Delivered to a `UIButton` `onLongPress` handler when a finger is held on the button past the
|
|
53
|
+
* host's long-press threshold. Like `TouchStartEvent` it can `track(...)` the rest of the gesture —
|
|
54
|
+
* so the common "press-and-hold, then drag to move" flow is one handler: start the drag from here. */
|
|
55
|
+
export class LongPressEvent<T = any> {
|
|
56
|
+
readonly type = "longpress"
|
|
57
|
+
readonly pointerId: number
|
|
58
|
+
readonly clientX: number
|
|
59
|
+
readonly clientY: number
|
|
60
|
+
/** World-space hit position (2D scenes only; undefined in 3D). */
|
|
61
|
+
worldX?: number
|
|
62
|
+
worldY?: number
|
|
63
|
+
target!: T
|
|
64
|
+
/** @internal — read by the dispatch system after the handler runs. */
|
|
65
|
+
_trackHandler?: TrackHandler
|
|
66
|
+
constructor(clientX: number, clientY: number, pointerId: number) {
|
|
67
|
+
this.clientX = clientX
|
|
68
|
+
this.clientY = clientY
|
|
69
|
+
this.pointerId = pointerId
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Capture subsequent moves/end for this pointer — drag the element after the hold. */
|
|
73
|
+
track(handler: TrackHandler): void {
|
|
74
|
+
this._trackHandler = handler
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// A host-registered platform view (map, QR scanner, camera preview, …) — the plugin surface of
|
|
2
|
+
// the Presentable model (docs/navigation-presentable-plan.md). The host registers a *capability*
|
|
3
|
+
// (`engine.registerView("map") { params, channel -> MapView(...) }`); when and with what to show
|
|
4
|
+
// it is app code. Dual-natured like every Presentable: a fullscreen destination
|
|
5
|
+
// (`Router.push(map)` / `map.open()`) AND an embeddable layout node (wire type `"native"` — place
|
|
6
|
+
// it among a screen's children, sized by its styles). One instance lives in one place at a time;
|
|
7
|
+
// pushing a mounted instance fullscreen is promotion (the native view reparents, state intact).
|
|
8
|
+
//
|
|
9
|
+
// Plugins ship a typed TS wrapper over the raw `call`/`on` channel — see QRScanner for the
|
|
10
|
+
// wrapper convention.
|
|
11
|
+
|
|
12
|
+
import { _channelCall, _channelEmit, _channelOff, _channelOn, type ChannelListeners } from "../runtime/channel"
|
|
13
|
+
import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "./presentable"
|
|
14
|
+
import { Element, type AnimateStyle, type BaseStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type Style } from "./UINode"
|
|
15
|
+
|
|
16
|
+
export type UINativeViewStyle = ElementStyle & DrawableStyle
|
|
17
|
+
|
|
18
|
+
export interface NativeView {
|
|
19
|
+
readonly type: "native",
|
|
20
|
+
/** The registered view kind this instance resolves to (the `registerView` name). */
|
|
21
|
+
readonly viewName: string,
|
|
22
|
+
/** Creation params, passed to the host factory (JSON-serializable). */
|
|
23
|
+
readonly params: any,
|
|
24
|
+
style: Style<this, UINativeViewStyle>,
|
|
25
|
+
animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>
|
|
26
|
+
animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
|
|
27
|
+
|
|
28
|
+
/** Show fullscreen as the current destination (see Presentable). */
|
|
29
|
+
open(options?: PresentOptions): void,
|
|
30
|
+
close(): void,
|
|
31
|
+
|
|
32
|
+
onOpen(callback: () => void): this
|
|
33
|
+
onClose(callback: () => void): this
|
|
34
|
+
onBackPressed(callback: () => void): this
|
|
35
|
+
|
|
36
|
+
/** Invoke a method on the native view (`map.call("setCenter", [lng, lat])`). Args and the
|
|
37
|
+
* result are JSON-serialized. Rejects if the host has no such view/method. */
|
|
38
|
+
call(method: string, ...args: any[]): Promise<any>
|
|
39
|
+
/** Subscribe to an event the native view emits (`map.on("markerTap", cb)`). */
|
|
40
|
+
on(event: string, callback: (data?: any) => void): this
|
|
41
|
+
off(event: string, callback: (data?: any) => void): this
|
|
42
|
+
|
|
43
|
+
onLayout(onLayout: OnLayoutCallback): this
|
|
44
|
+
setClass(name: string, enabled: boolean): this
|
|
45
|
+
toggleClass(name: string): this
|
|
46
|
+
hasClass(name: string): boolean
|
|
47
|
+
bindClass(name: string, fn: () => boolean): this
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let nextViewId = 1
|
|
51
|
+
|
|
52
|
+
/** @internal Base for typed plugin wrappers (QRScanner, CameraView — src/plugins/): subclass it,
|
|
53
|
+
* pin the viewName/params, and layer typed methods over the raw `call`/`on` channel. Not a
|
|
54
|
+
* public global — apps use the `NativeView(name)` factory below. */
|
|
55
|
+
export class NativeViewElement extends Element<"native"> implements Presentable {
|
|
56
|
+
readonly viewName: string
|
|
57
|
+
readonly params: any
|
|
58
|
+
/** @internal stable per-instance handle for the viewCall channel + promotion identity. */
|
|
59
|
+
readonly _viewId: number
|
|
60
|
+
/** @internal event listeners by event name — the host invokes these (vlist pcl/psl convention)
|
|
61
|
+
* or calls _emitViewEvent. */
|
|
62
|
+
readonly nvl: ChannelListeners = {}
|
|
63
|
+
readonly ol: (() => void)[] = []
|
|
64
|
+
readonly cl: (() => void)[] = []
|
|
65
|
+
_backButtonCallback?: () => void
|
|
66
|
+
private _vd?: object
|
|
67
|
+
|
|
68
|
+
constructor(viewName: string, params: any) {
|
|
69
|
+
super("native", {})
|
|
70
|
+
this.viewName = viewName
|
|
71
|
+
this.params = params
|
|
72
|
+
this._viewId = nextViewId++
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
|
|
76
|
+
_viewDesc(): object {
|
|
77
|
+
return this._vd ??= { type: "native", viewName: this.viewName, params: this.params, viewId: this._viewId, _p: this }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
open(options?: PresentOptions): void {
|
|
81
|
+
if (!_navSupported()) {
|
|
82
|
+
throw new Error(`NativeView("${this.viewName}") is not supported on this host (requires _creatorUI.openView)`)
|
|
83
|
+
}
|
|
84
|
+
_bumpNavEpoch()
|
|
85
|
+
_creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
|
|
86
|
+
_setCurrent(this)
|
|
87
|
+
}
|
|
88
|
+
close(): void {
|
|
89
|
+
_bumpNavEpoch()
|
|
90
|
+
if (_navSupported()) _creatorUI.closeView!()
|
|
91
|
+
if (Presentable.current === this) _setCurrent(null)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
onOpen(callback: () => void): this {
|
|
95
|
+
this.ol.push(callback)
|
|
96
|
+
return this
|
|
97
|
+
}
|
|
98
|
+
onClose(callback: () => void): this {
|
|
99
|
+
this.cl.push(callback)
|
|
100
|
+
return this
|
|
101
|
+
}
|
|
102
|
+
onBackPressed(callback: () => void): this {
|
|
103
|
+
this._backButtonCallback = callback
|
|
104
|
+
return this
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
call(method: string, ...args: any[]): Promise<any> {
|
|
108
|
+
return _channelCall(
|
|
109
|
+
typeof _creatorUI !== "undefined" ? _creatorUI.viewCall : undefined,
|
|
110
|
+
`NativeView("${this.viewName}") is not supported on this host (requires _creatorUI.viewCall)`,
|
|
111
|
+
this._viewId, method, args)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
on(event: string, callback: (data?: any) => void): this {
|
|
115
|
+
_channelOn(this.nvl, event, callback)
|
|
116
|
+
return this
|
|
117
|
+
}
|
|
118
|
+
off(event: string, callback: (data?: any) => void): this {
|
|
119
|
+
_channelOff(this.nvl, event, callback)
|
|
120
|
+
return this
|
|
121
|
+
}
|
|
122
|
+
/** @internal host entry: deliver a native event (payload as a JSON string, or omitted). */
|
|
123
|
+
_emitViewEvent(event: string, dataJson?: string): void {
|
|
124
|
+
_channelEmit(this.nvl, event, dataJson)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create an instance of a host-registered platform view. `NativeView.isSupported(name)` reports
|
|
130
|
+
* whether this host registered a factory for it. Style via the chained `.style()`.
|
|
131
|
+
*/
|
|
132
|
+
// PURE IIFE (not a plain function + expando assignment) so an app that never uses NativeView
|
|
133
|
+
// tree-shakes it away — top-level property assignments read as side effects to the bundler.
|
|
134
|
+
export const NativeView: {
|
|
135
|
+
(name: string, params?: object): NativeView
|
|
136
|
+
/** Whether this host registered a `registerView` factory under `name`. */
|
|
137
|
+
isSupported(name: string): boolean
|
|
138
|
+
} = /*#__PURE__*/ (() => {
|
|
139
|
+
const factory = (name: string, params: object = {}): NativeView =>
|
|
140
|
+
new NativeViewElement(name, params) as unknown as NativeView
|
|
141
|
+
factory.isSupported = (name: string): boolean =>
|
|
142
|
+
typeof _creatorUI !== "undefined" && typeof _creatorUI.isViewSupported === "function" && _creatorUI.isViewSupported(name)
|
|
143
|
+
return factory
|
|
144
|
+
})()
|
package/runtime/sdk/ui/UI.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Renderer-side contract + per-renderer status (DOM / canvas-ui / desktop) for every component
|
|
2
2
|
// below is tracked in packages/canvas-ui/COMPONENTS.md.
|
|
3
3
|
export { UIButton, type UIButtonStyle } from './UIButton'
|
|
4
|
-
export { UIScreen, type UIScreenStyle } from './UIScreen'
|
|
4
|
+
export { UIScreen, type UIScreenStyle, type UIScrollableScreen } from './UIScreen'
|
|
5
5
|
export { UISpacer, type UISpacerStyle } from './UISpacer'
|
|
6
6
|
export { UIText, type UITextStyle } from './UIText'
|
|
7
7
|
export { registerFont } from './fonts'
|
|
@@ -18,6 +18,10 @@ export { UIScreenHost, type UIScreenHostStyle } from './UIScreenHost'
|
|
|
18
18
|
export { UIVirtualizedList } from './UIVirtualizedList'
|
|
19
19
|
|
|
20
20
|
export { Router } from './router'
|
|
21
|
+
export { NativeView, type UINativeViewStyle } from './NativeView'
|
|
22
|
+
// Presentable is both the interface and its runtime companion (Presentable.current).
|
|
23
|
+
export { Presentable } from './presentable'
|
|
24
|
+
export type { PresentOptions, Transition, TransitionName, TransitionSpec, TransitionTransform } from './presentable'
|
|
21
25
|
|
|
22
26
|
// Internal: memoized map for reactive children — the chisel `reactiveUi` pass rewrites
|
|
23
27
|
// `list.map(fn)` inside children bindings to `__uiMap(list, fn, slot)` calls.
|