lecodes-cli 0.6.4 → 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/README.md +1 -0
- package/dist/index.js +920 -636
- package/package.json +9 -4
- package/runtime/scene-harness.json +1 -0
- package/runtime/sdk/compile/assetMacro.ts +4 -3
- package/runtime/sdk/compile/bundler.ts +35 -3
- package/runtime/sdk/compile/compileProject.ts +14 -2
- package/runtime/sdk/compile/libraryImports.ts +47 -0
- package/runtime/sdk/compile/sceneEditor.ts +16 -4
- package/runtime/sdk/core/Aspect.ts +37 -0
- package/runtime/sdk/core/InspectorUI.ts +212 -0
- package/runtime/sdk/core/fields.ts +15 -3
- package/runtime/sdk/g2/Scene2D.ts +51 -5
- package/runtime/sdk/gl/Model.ts +9 -0
- package/runtime/sdk/gl/Node.ts +4 -1
- package/runtime/sdk/gl/Scene.ts +76 -11
- package/runtime/sdk/gl/scenarios.ts +349 -0
- package/runtime/sdk/inject.ts +28 -8
- package/runtime/sdk/kit/UITabs.ts +105 -0
- 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/scene/defineScene.ts +651 -17
- package/runtime/sdk/scene/editorPlugins.ts +86 -0
- package/runtime/sdk/ui/NativeView.ts +144 -0
- package/runtime/sdk/ui/UI.ts +6 -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 +86 -29
- package/runtime/sdk/ui/UIScreenHost.ts +213 -0
- 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,213 @@
|
|
|
1
|
+
import { ContainerElement, type AnimateStyle, type BaseStyle, type ChildrenFn, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type Style, type UINodeChild } from "./UINode"
|
|
2
|
+
import type { UIScreen } from "./UIScreen"
|
|
3
|
+
|
|
4
|
+
// UIScreenHost — the system's ONE screen host: an element whose pages are full UIScreens, each laid
|
|
5
|
+
// out to the host's box (the same mechanism screens use, with the "device" swapped for the slot).
|
|
6
|
+
// With several screens it pages between them (wrapping each platform's native pager); with a single
|
|
7
|
+
// screen it simply embeds it — a refreshable region under a fixed header, or a screen-swap slot via
|
|
8
|
+
// setContent(next). UITabs is pure TS over a swipe-disabled UIScreenHost.
|
|
9
|
+
//
|
|
10
|
+
// Pages are screens ONLY — no plain elements. Every page then carries the full screen contract
|
|
11
|
+
// (onOpen/onClose from the settle lifecycle, makeScrollable()/onRefresh), and every platform
|
|
12
|
+
// implements exactly one page kind: mount the screen root in the slot, lay it out to the slot size.
|
|
13
|
+
// To host a plain element, wrap it: UIScreenHost(UIScreen([el])).
|
|
14
|
+
|
|
15
|
+
/** A hosted page: any UIScreen (a `UIScrollableScreen` is a subtype, so it's accepted too). Sizing
|
|
16
|
+
* styles on it are ignored — screens are roots sized by their host. */
|
|
17
|
+
export type HostedScreen = UIScreen
|
|
18
|
+
/** Reactive pages — ChildrenFn narrowed to screens. */
|
|
19
|
+
export type ScreensFn = () => (HostedScreen | null | undefined | false)[]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Transition for `replace()`. The `Router.replace` catalogue, plus `"push"`/`"pop"` — the native
|
|
23
|
+
* UINavigationController feel (the incoming/outgoing screen slides full-width while the other
|
|
24
|
+
* parallaxes ~30% under a dim overlay with a soft edge shadow). Use `"push"` to go forward, `"pop"`
|
|
25
|
+
* to go back. (Animated transitions are native-only; web swaps instantly.)
|
|
26
|
+
*/
|
|
27
|
+
export type ScreenHostTransition =
|
|
28
|
+
| "push" | "pop"
|
|
29
|
+
| "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom"
|
|
30
|
+
| "zoom" | "zoom-in" | "zoom-out" | "fade" | "none"
|
|
31
|
+
|
|
32
|
+
export type UIScreenHostStyle = ElementStyle & DrawableStyle & {
|
|
33
|
+
// Paging axis. Default "horizontal". Read by the host at mount; live-updatable via .style().
|
|
34
|
+
scrollDirection?: "horizontal" | "vertical",
|
|
35
|
+
// false = programmatic-only paging (no user swipe) — the mode UITabs uses. Default true.
|
|
36
|
+
swipeEnabled?: boolean,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UIScreenHost {
|
|
40
|
+
readonly type: "screenhost",
|
|
41
|
+
style: Style<this, UIScreenHostStyle>,
|
|
42
|
+
animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>,
|
|
43
|
+
animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>,
|
|
44
|
+
|
|
45
|
+
/** Current settled page index. */
|
|
46
|
+
readonly page: number,
|
|
47
|
+
/** Programmatic page switch. `animated` defaults to true (ignored when swipe physics can't run). */
|
|
48
|
+
setPage(index: number, animated?: boolean): this,
|
|
49
|
+
/** Fires when the settled page changes (after the swipe/scroll comes to rest). */
|
|
50
|
+
onPageChange(callback: (index: number) => void): this,
|
|
51
|
+
/** Continuous fractional scroll position (e.g. 1.35) — for tab-strip indicators / page dots. */
|
|
52
|
+
onPageScroll(callback: (position: number) => void): this,
|
|
53
|
+
|
|
54
|
+
append(...screens: HostedScreen[]): this,
|
|
55
|
+
insert(index: number, ...screens: HostedScreen[]): this,
|
|
56
|
+
remove(...screens: HostedScreen[]): this,
|
|
57
|
+
/** Replace the pages. A single screen swaps the hosted screen in place (old page onClose → new page onOpen). */
|
|
58
|
+
setContent(screens: HostedScreen | HostedScreen[] | ScreensFn): this,
|
|
59
|
+
/**
|
|
60
|
+
* Replace the page at `index` with a new screen, animating the swap when that page is the visible
|
|
61
|
+
* one (off-screen pages swap instantly). The settled page index is preserved — a replace is not a
|
|
62
|
+
* page change. This is the push/pop primitive: keep your own stack of screens and replace the
|
|
63
|
+
* visible slot with a forward transition to push, a backward one to pop.
|
|
64
|
+
*/
|
|
65
|
+
replace(index: number, screen: HostedScreen, transition?: ScreenHostTransition): this,
|
|
66
|
+
|
|
67
|
+
readonly children: UINodeChild[],
|
|
68
|
+
|
|
69
|
+
onLayout(onLayout: OnLayoutCallback): this,
|
|
70
|
+
|
|
71
|
+
setClass(name: string, enabled: boolean): this,
|
|
72
|
+
toggleClass(name: string): this,
|
|
73
|
+
hasClass(name: string): boolean,
|
|
74
|
+
bindClass(name: string, fn: () => boolean): this,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class ScreenHostElement extends ContainerElement<"screenhost"> {
|
|
78
|
+
// --- read by the host at mount (createScreenHostSystem) ---
|
|
79
|
+
_page = 0 // settled index; host writes it back on settle
|
|
80
|
+
_dir: "horizontal" | "vertical" = "horizontal"
|
|
81
|
+
_swipe = true
|
|
82
|
+
|
|
83
|
+
// --- listeners the host invokes directly (same pattern as UIScrollable.sl / vlist) ---
|
|
84
|
+
readonly pcl: ((index: number) => void)[] = [] // onPageChange
|
|
85
|
+
readonly psl: ((position: number) => void)[] = [] // onPageScroll
|
|
86
|
+
|
|
87
|
+
get page(): number {
|
|
88
|
+
return this._page
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setPage(index: number, animated = true): this {
|
|
92
|
+
if (this._id !== 0) {
|
|
93
|
+
_creatorUI.command(this, "setPage", index, animated)
|
|
94
|
+
} else {
|
|
95
|
+
this._page = index // remembered as the initial page until the host mounts
|
|
96
|
+
}
|
|
97
|
+
return this
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
onPageChange(callback: (index: number) => void): this {
|
|
101
|
+
this.pcl.push(callback)
|
|
102
|
+
return this
|
|
103
|
+
}
|
|
104
|
+
onPageScroll(callback: (position: number) => void): this {
|
|
105
|
+
this.psl.push(callback)
|
|
106
|
+
return this
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Pages are separate layout roots, not yoga children, so the generic insertNode/removeNode path
|
|
110
|
+
// doesn't apply. Keep `children` in sync and ask the host to reconcile pages by node identity.
|
|
111
|
+
append(...nodes: UINodeChild[]): this {
|
|
112
|
+
this.children.push(...nodes)
|
|
113
|
+
if (this._id !== 0) _creatorUI.command(this, "syncPages")
|
|
114
|
+
return this
|
|
115
|
+
}
|
|
116
|
+
insert(index: number, ...nodes: UINodeChild[]): this {
|
|
117
|
+
this.children.splice(index, 0, ...nodes)
|
|
118
|
+
if (this._id !== 0) _creatorUI.command(this, "syncPages")
|
|
119
|
+
return this
|
|
120
|
+
}
|
|
121
|
+
remove(...nodes: UINodeChild[]): this {
|
|
122
|
+
const set = new Set(nodes)
|
|
123
|
+
this.children = this.children.filter(c => !set.has(c as UINodeChild))
|
|
124
|
+
if (this._id !== 0) _creatorUI.command(this, "syncPages")
|
|
125
|
+
return this
|
|
126
|
+
}
|
|
127
|
+
setContent(nodes: UINodeChild | UINodeChild[] | ChildrenFn): this {
|
|
128
|
+
if (typeof nodes === "function") {
|
|
129
|
+
// Reactive pages: the base binding drives _reconcile, which calls our insert/remove above.
|
|
130
|
+
return super.setContent(nodes)
|
|
131
|
+
}
|
|
132
|
+
this.children = Array.isArray(nodes) ? [...nodes] : [nodes]
|
|
133
|
+
if (this._id !== 0) _creatorUI.command(this, "syncPages")
|
|
134
|
+
return this
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Replace one page in place, animated. The node travels via children[index] (the command channel
|
|
138
|
+
// carries only index + transition); the host reads it back and swaps the page, animating the swap
|
|
139
|
+
// when `index` is the visible page. Before mount it just seeds the initial content at that index.
|
|
140
|
+
replace(index: number, screen: UINodeChild, transition: ScreenHostTransition = "fade"): this {
|
|
141
|
+
if (index < 0 || index >= this.children.length) return this
|
|
142
|
+
this.children[index] = screen
|
|
143
|
+
if (this._id !== 0) _creatorUI.command(this, "replace", index, transition)
|
|
144
|
+
return this
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// scrollDirection/swipeEnabled are host behavior, not layout — intercept them here so they never
|
|
148
|
+
// reach the layout engine, and forward live changes to the host. Everything else flows to the base
|
|
149
|
+
// style proxy unchanged (including property get/set via the forwarding Proxy below).
|
|
150
|
+
private _hostStyleProxy?: any
|
|
151
|
+
get style(): any {
|
|
152
|
+
if (this._hostStyleProxy) return this._hostStyleProxy
|
|
153
|
+
const base = super.style
|
|
154
|
+
const self = this
|
|
155
|
+
const apply = (s: any) => {
|
|
156
|
+
if (s && typeof s === "object") {
|
|
157
|
+
if ("scrollDirection" in s && typeof s.scrollDirection !== "function") {
|
|
158
|
+
self._dir = s.scrollDirection
|
|
159
|
+
delete s.scrollDirection
|
|
160
|
+
if (self._id !== 0) _creatorUI.command(self, "setDirection", self._dir)
|
|
161
|
+
}
|
|
162
|
+
if ("swipeEnabled" in s && typeof s.swipeEnabled !== "function") {
|
|
163
|
+
self._swipe = s.swipeEnabled
|
|
164
|
+
delete s.swipeEnabled
|
|
165
|
+
if (self._id !== 0) _creatorUI.command(self, "setSwipe", self._swipe)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return base(s)
|
|
169
|
+
}
|
|
170
|
+
this._hostStyleProxy = new Proxy(apply, {
|
|
171
|
+
apply: (_t, _thisArg, args) => apply(args[0]),
|
|
172
|
+
get: (_t, k) => (base as any)[k],
|
|
173
|
+
set: (_t, k, v) => { (base as any)[k] = v; return true },
|
|
174
|
+
})
|
|
175
|
+
return this._hostStyleProxy
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function makeScreenHost(style: UIScreenHostStyle | null, children: UINodeChild[] | ChildrenFn): ScreenHostElement {
|
|
180
|
+
const dir = style?.scrollDirection
|
|
181
|
+
const swipe = style?.swipeEnabled
|
|
182
|
+
if (style) {
|
|
183
|
+
delete style.scrollDirection
|
|
184
|
+
delete style.swipeEnabled
|
|
185
|
+
}
|
|
186
|
+
const el = new ScreenHostElement("screenhost", style ?? {}, children)
|
|
187
|
+
if (dir !== undefined) el._dir = dir
|
|
188
|
+
if (swipe !== undefined) el._swipe = swipe
|
|
189
|
+
return el
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A screen is the only non-array, non-function, non-style argument the factory accepts.
|
|
193
|
+
const isScreenArg = (v: any): boolean => v != null && typeof v === "object" && v.type === "screen"
|
|
194
|
+
const asPages = (v: any): UINodeChild[] | ChildrenFn => (Array.isArray(v) || typeof v === "function") ? v : [v]
|
|
195
|
+
|
|
196
|
+
export function UIScreenHost(): UIScreenHost
|
|
197
|
+
export function UIScreenHost(screen: HostedScreen): UIScreenHost
|
|
198
|
+
export function UIScreenHost(screens: HostedScreen[] | ScreensFn): UIScreenHost
|
|
199
|
+
export function UIScreenHost(style: UIScreenHostStyle): UIScreenHost
|
|
200
|
+
export function UIScreenHost(style: UIScreenHostStyle, screens: HostedScreen | HostedScreen[] | ScreensFn): UIScreenHost
|
|
201
|
+
export function UIScreenHost(...args: [] | [HostedScreen | HostedScreen[] | ScreensFn | UIScreenHostStyle] | [UIScreenHostStyle, HostedScreen | HostedScreen[] | ScreensFn]): UIScreenHost {
|
|
202
|
+
if (args.length === 0) {
|
|
203
|
+
return makeScreenHost({}, [])
|
|
204
|
+
} else if (args.length === 1) {
|
|
205
|
+
if (Array.isArray(args[0]) || typeof args[0] === "function" || isScreenArg(args[0])) {
|
|
206
|
+
return makeScreenHost({}, asPages(args[0]))
|
|
207
|
+
} else {
|
|
208
|
+
return makeScreenHost(args[0] as UIScreenHostStyle, [])
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
return makeScreenHost(args[0] as UIScreenHostStyle, asPages(args[1]))
|
|
212
|
+
}
|
|
213
|
+
}
|
package/runtime/sdk/ui/UIText.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createBinding } from "../core/signals"
|
|
2
2
|
import { Element, type AnimateStyle, type BaseStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type Style, type TextStyle } from "./UINode"
|
|
3
3
|
|
|
4
|
-
export type UITextStyle = ElementStyle & TextStyle
|
|
4
|
+
export type UITextStyle = ElementStyle & TextStyle & DrawableStyle
|
|
5
5
|
|
|
6
6
|
export interface UIText {
|
|
7
7
|
readonly type: "text",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { VideoPlayer } from "../runtime/media"
|
|
2
|
+
import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "./presentable"
|
|
2
3
|
import { Element, type AnimateStyle, type AppearStyle, type BaseStyle, type DisappearStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type Style, type StyleFn } from "./UINode"
|
|
3
4
|
|
|
4
5
|
export type UIVideoStyle = ElementStyle & DrawableStyle & { objectFit?: "cover" | "contain" | "fill" }
|
|
@@ -10,6 +11,14 @@ export interface UIVideo {
|
|
|
10
11
|
animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>
|
|
11
12
|
animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
|
|
12
13
|
|
|
14
|
+
/** Show fullscreen as the current destination. Promotion: if this video is mounted in a screen,
|
|
15
|
+
* the SAME native player moves fullscreen — playback uninterrupted; a pop moves it back. */
|
|
16
|
+
open(options?: PresentOptions): void
|
|
17
|
+
close(): void
|
|
18
|
+
onOpen(callback: () => void): this
|
|
19
|
+
onClose(callback: () => void): this
|
|
20
|
+
onBackPressed(callback: () => void): this
|
|
21
|
+
|
|
13
22
|
onLayout(onLayout: OnLayoutCallback): this
|
|
14
23
|
|
|
15
24
|
setClass(name: string, enabled: boolean): this
|
|
@@ -18,7 +27,7 @@ export interface UIVideo {
|
|
|
18
27
|
bindClass(name: string, fn: () => boolean): this
|
|
19
28
|
}
|
|
20
29
|
|
|
21
|
-
class VideoElement extends Element<"video"> {
|
|
30
|
+
class VideoElement extends Element<"video"> implements Presentable {
|
|
22
31
|
private _playerId: number
|
|
23
32
|
private _player: VideoPlayer
|
|
24
33
|
constructor(style: any, player: VideoPlayer) {
|
|
@@ -30,6 +39,43 @@ class VideoElement extends Element<"video"> {
|
|
|
30
39
|
get player() {
|
|
31
40
|
return this._player
|
|
32
41
|
}
|
|
42
|
+
|
|
43
|
+
// ---- Presentable (docs/navigation-presentable-plan.md) ----
|
|
44
|
+
readonly ol: (() => void)[] = []
|
|
45
|
+
readonly cl: (() => void)[] = []
|
|
46
|
+
_backButtonCallback?: () => void
|
|
47
|
+
private _vd?: object
|
|
48
|
+
|
|
49
|
+
/** @internal wire descriptor — type "videoView" (distinct from the "video" node type). */
|
|
50
|
+
_viewDesc(): object {
|
|
51
|
+
return this._vd ??= { type: "videoView", node: this, _p: this }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
open(options?: PresentOptions): void {
|
|
55
|
+
if (!_navSupported()) {
|
|
56
|
+
throw new Error("Fullscreen video is not supported on this host (requires _creatorUI.openView)")
|
|
57
|
+
}
|
|
58
|
+
_bumpNavEpoch()
|
|
59
|
+
_creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
|
|
60
|
+
_setCurrent(this)
|
|
61
|
+
}
|
|
62
|
+
close(): void {
|
|
63
|
+
_bumpNavEpoch()
|
|
64
|
+
if (_navSupported()) _creatorUI.closeView!()
|
|
65
|
+
if (Presentable.current === this) _setCurrent(null)
|
|
66
|
+
}
|
|
67
|
+
onOpen(callback: () => void): this {
|
|
68
|
+
this.ol.push(callback)
|
|
69
|
+
return this
|
|
70
|
+
}
|
|
71
|
+
onClose(callback: () => void): this {
|
|
72
|
+
this.cl.push(callback)
|
|
73
|
+
return this
|
|
74
|
+
}
|
|
75
|
+
onBackPressed(callback: () => void): this {
|
|
76
|
+
this._backButtonCallback = callback
|
|
77
|
+
return this
|
|
78
|
+
}
|
|
33
79
|
}
|
|
34
80
|
|
|
35
81
|
export function UIVideo(player: VideoPlayer): UIVideo
|
|
@@ -40,4 +86,4 @@ export function UIVideo(...args: [VideoPlayer] | [UIVideoStyle, VideoPlayer]): U
|
|
|
40
86
|
} else {
|
|
41
87
|
return new VideoElement(args[0], args[1])
|
|
42
88
|
}
|
|
43
|
-
}
|
|
89
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TouchStartEvent } from "../runtime/touch"
|
|
2
|
+
import { _descOf, _navSupported, Presentable } from "./presentable"
|
|
2
3
|
import { ContainerElement, type AnimateStyle, type BaseStyle, type Color, type ContainerStyle, type DrawableStyle, type OnLayoutCallback, type PaddingStyle, type PositionStyle, type Style, type UINodeChild, type ChildrenFn } from "./UINode"
|
|
3
4
|
|
|
4
5
|
|
|
@@ -15,7 +16,23 @@ export interface UIWidget {
|
|
|
15
16
|
animateTo: AnimateStyle<this, DrawableStyle & BaseStyle & { overlayColor?: Color | null }>
|
|
16
17
|
animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle & { overlayColor?: Color | null }>
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Pre-bind this widget to a destination (a `Scene`, a `UIScreen`, …) that is not current yet —
|
|
21
|
+
* e.g. HUD for an `ARScene` built before opening it, so it's in place from the first frame.
|
|
22
|
+
* `show()` binds to the *current* destination automatically, so this is only needed ahead of
|
|
23
|
+
* time (or to target a non-current owner). Sticky: an explicit owner wins over the automatic
|
|
24
|
+
* one until `attachTo(null)` (= back to automatic). Set it while the widget is hidden.
|
|
25
|
+
*/
|
|
26
|
+
attachTo(owner: Presentable | null): this,
|
|
27
|
+
|
|
28
|
+
/** Show the widget, attached to the current destination (or the `attachTo` owner): it mounts
|
|
29
|
+
* inside that page, shows/hides with it and rides its transition — a dialog opened on a screen
|
|
30
|
+
* disappears when the user navigates away. For an overlay that survives navigation use
|
|
31
|
+
* `showDetached()`. */
|
|
18
32
|
show(): void,
|
|
33
|
+
/** Show the widget as a global overlay above every destination — it stays visible across
|
|
34
|
+
* navigation (mini-player, global loader). */
|
|
35
|
+
showDetached(): void,
|
|
19
36
|
hide(): void,
|
|
20
37
|
|
|
21
38
|
readonly isShow: boolean,
|
|
@@ -41,8 +58,24 @@ export interface UIWidget {
|
|
|
41
58
|
|
|
42
59
|
export class WidgetElement extends ContainerElement<"widget"> {
|
|
43
60
|
scrollable = false as const
|
|
61
|
+
_owner?: Presentable
|
|
62
|
+
attachTo(owner: Presentable | null): this {
|
|
63
|
+
this._owner = owner ?? undefined
|
|
64
|
+
return this
|
|
65
|
+
}
|
|
44
66
|
show(): void {
|
|
45
|
-
|
|
67
|
+
// Explicit attachTo() wins; otherwise bind to whatever is on screen right now — re-showing
|
|
68
|
+
// the same widget from another page re-attaches it there.
|
|
69
|
+
const owner = this._owner ?? Presentable.current
|
|
70
|
+
if (owner && _navSupported()) {
|
|
71
|
+
_creatorUI.showWidget(this, _descOf(owner))
|
|
72
|
+
} else {
|
|
73
|
+
// Legacy host (or nothing presented yet): global overlay.
|
|
74
|
+
_creatorUI.showWidget(this)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
showDetached(): void {
|
|
78
|
+
_creatorUI.showWidget(this)
|
|
46
79
|
}
|
|
47
80
|
hide(): void {
|
|
48
81
|
_creatorUI.hideWidget(this)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The Presentable contract — the one navigation model (docs/navigation-presentable-plan.md).
|
|
2
|
+
// Anything that can occupy the app's main slot implements it: UIScreen, Scene (+ARScene),
|
|
3
|
+
// Scene2D, NativeView, UIVideo. Exactly one Presentable is visible at a time — a screen no
|
|
4
|
+
// longer overlays a scene, it replaces it (with a transition); UI over a scene goes through
|
|
5
|
+
// `UIWidget.attachTo(scene)`.
|
|
6
|
+
//
|
|
7
|
+
// This module is dependency-free on purpose: gl/ and g2/ import it, so it must not pull any UI
|
|
8
|
+
// element code into engine-only bundles.
|
|
9
|
+
|
|
10
|
+
/** Built-in transition catalog. `push`/`pop` are the stacked-navigation pair (slide + parallax +
|
|
11
|
+
* dim + edge shadow); the rest are simple two-view moves. */
|
|
12
|
+
export type TransitionName =
|
|
13
|
+
| "push" | "pop"
|
|
14
|
+
| "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom"
|
|
15
|
+
| "zoom" | "zoom-in" | "zoom-out" | "fade" | "none"
|
|
16
|
+
|
|
17
|
+
/** Start/end pose of one side of a custom transition. `x`/`y` are logical px, or a percentage of
|
|
18
|
+
* the destination's size (e.g. `"100%"` = one full width to the right). */
|
|
19
|
+
export type TransitionTransform = {
|
|
20
|
+
x?: number | `${number}%`
|
|
21
|
+
y?: number | `${number}%`
|
|
22
|
+
scale?: number
|
|
23
|
+
opacity?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A custom transition: declarative poses for the incoming/outgoing destination, played natively.
|
|
28
|
+
* Serializes onto each platform's existing transition machinery — there is no per-frame JS.
|
|
29
|
+
* Fields omitted keep the identity pose. Interactive back-swipe stays system-native (push/pop
|
|
30
|
+
* only); a spec applies to non-interactive transitions.
|
|
31
|
+
*/
|
|
32
|
+
export interface TransitionSpec {
|
|
33
|
+
/** Duration in ms (default 300). */
|
|
34
|
+
duration?: number
|
|
35
|
+
/** cubic-bezier control points (default ease-out). */
|
|
36
|
+
easing?: [number, number, number, number]
|
|
37
|
+
/** The entering destination animates FROM this pose to identity. */
|
|
38
|
+
incoming?: { from?: TransitionTransform }
|
|
39
|
+
/** The leaving destination animates from identity TO this pose. */
|
|
40
|
+
outgoing?: { to?: TransitionTransform }
|
|
41
|
+
/** Scrim alpha (0..1) painted over whichever destination is underneath. */
|
|
42
|
+
dim?: number
|
|
43
|
+
/** Which side stacks on top during the animation (default "incoming"). On Android, transitions
|
|
44
|
+
* involving a scene always animate the screen side on top — the scene view itself never moves. */
|
|
45
|
+
onTop?: "incoming" | "outgoing"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type Transition = TransitionName | TransitionSpec
|
|
49
|
+
|
|
50
|
+
export type PresentOptions = {
|
|
51
|
+
/** Transition to play while this destination replaces the current one (default "none" for a
|
|
52
|
+
* direct open(); the Router applies its own defaults — push/fade/pop). */
|
|
53
|
+
transition?: Transition
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Anything that can be shown as the app's current destination: a `UIScreen`, a `Scene` /
|
|
58
|
+
* `ARScene`, a `Scene2D`, a `NativeView`, or a `UIVideo`. One Presentable is visible at a time;
|
|
59
|
+
* open it directly (`p.open()` — replaces the current destination, suspending an active Router
|
|
60
|
+
* until `Router.restore()`) or navigate with `Router.push/replace/pop`.
|
|
61
|
+
*
|
|
62
|
+
* `onOpen`/`onClose` are the presentation lifecycle (fired when the destination becomes / stops
|
|
63
|
+
* being the visible one — including router pushes covering it and pops revealing it).
|
|
64
|
+
*/
|
|
65
|
+
export interface Presentable {
|
|
66
|
+
// Return type covers the implementers: UIScreen → void, Scene2D → this, ARScene → Promise.
|
|
67
|
+
open(options?: PresentOptions): void | this | Promise<void>
|
|
68
|
+
close(): void
|
|
69
|
+
onOpen(callback: () => void): this
|
|
70
|
+
onClose(callback: () => void): this
|
|
71
|
+
onBackPressed(callback: () => void): this
|
|
72
|
+
/** @internal Async work that must finish BEFORE this destination replaces the current one
|
|
73
|
+
* (camera permission, warm render, session launch — ARScene). The previous destination —
|
|
74
|
+
* typically a loading screen — stays visible while it runs; `open()` and `Router.push/replace`
|
|
75
|
+
* await it and skip the swap entirely if it rejects (permission denied, or superseded by
|
|
76
|
+
* another navigation that happened while preparing). */
|
|
77
|
+
_prepare?(): Promise<void>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The destination visible right now. Set optimistically by every successful open()/router
|
|
81
|
+
// navigation, cleared by close(), and corrected by router onChange (hosts hand the wire desc
|
|
82
|
+
// back on every stack change, including back gestures — `_p` resolves it to the SDK instance).
|
|
83
|
+
let current: Presentable | null = null
|
|
84
|
+
/** @internal Call whenever the visible destination changes. */
|
|
85
|
+
export const _setCurrent = (p: Presentable | null): void => { current = p }
|
|
86
|
+
|
|
87
|
+
/** Runtime companion of the `Presentable` interface (declaration merging): the navigation
|
|
88
|
+
* state that isn't tied to the Router. */
|
|
89
|
+
export const Presentable = /*#__PURE__*/ Object.freeze({
|
|
90
|
+
/** The destination visible right now (a `UIScreen`, `Scene`, `Scene2D`, `NativeView` or
|
|
91
|
+
* `UIVideo`), or `null` before the first open. Distinct from `Router.current` — that is the
|
|
92
|
+
* top of the router stack, which stays meaningful while the router is suspended by a direct
|
|
93
|
+
* `open()`; this is what is actually on screen. `UIWidget.show()` attaches to it. */
|
|
94
|
+
get current(): Presentable | null { return current },
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Monotonic navigation epoch: every destination change (open/close/router nav) bumps it. Async
|
|
98
|
+
// preparers capture the epoch before their awaits and reject as "superseded" if it moved — so a
|
|
99
|
+
// slow AR prepare can't steal the screen from a navigation the user made in the meantime.
|
|
100
|
+
let navEpoch = 0
|
|
101
|
+
/** @internal */
|
|
102
|
+
export const _navEpoch = (): number => navEpoch
|
|
103
|
+
/** @internal Call on every action that changes the visible destination. */
|
|
104
|
+
export const _bumpNavEpoch = (): void => { navEpoch++ }
|
|
105
|
+
|
|
106
|
+
/** @internal Does this host implement the Presentable navigation surface? The SDK falls back to
|
|
107
|
+
* the legacy openScreen/openScene bridges when it doesn't (screens keep working; scene/native
|
|
108
|
+
* destinations in the Router then throw). */
|
|
109
|
+
export const _navSupported = (): boolean =>
|
|
110
|
+
typeof _creatorUI !== "undefined" && typeof _creatorUI.openView === "function"
|
|
111
|
+
|
|
112
|
+
/** @internal The wire descriptor for a Presentable. Screens (and anything without `_viewDesc`)
|
|
113
|
+
* travel as themselves; scenes/native views/video provide a stable descriptor object carrying
|
|
114
|
+
* `_p` (the SDK instance) so hosts can hand it back through router onChange / lifecycle. */
|
|
115
|
+
export const _descOf = (p: Presentable): object =>
|
|
116
|
+
typeof (p as any)._viewDesc === "function" ? (p as any)._viewDesc() : (p as object)
|
package/runtime/sdk/ui/router.ts
CHANGED
|
@@ -1,68 +1,112 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { _bumpNavEpoch, _descOf, _navSupported, _setCurrent, Presentable, type Transition } from "./presentable"
|
|
2
2
|
|
|
3
3
|
type RouterOptions = {
|
|
4
4
|
showDefaultBackButton?: boolean
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
type NavigateOptions = {
|
|
8
|
+
/** Transition to play. Defaults: `"push"` for push, `"fade"` for replace, `"pop"` for pop. */
|
|
9
|
+
transition?: Transition
|
|
10
|
+
}
|
|
11
|
+
|
|
7
12
|
type Router = {
|
|
8
|
-
get current():
|
|
9
|
-
init
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
get current(): Presentable
|
|
14
|
+
init(homePage: Presentable, opts?: RouterOptions): void
|
|
15
|
+
/** Push a destination onto the stack — a `UIScreen`, a `Scene`, a `Scene2D`, a `NativeView`,
|
|
16
|
+
* or a `UIVideo`. The back gesture/button pops it. Destinations with async preparation
|
|
17
|
+
* (`ARScene`: camera permission + warm render) return a Promise — the current page stays
|
|
18
|
+
* visible until they're ready, and a rejection (permission denied / superseded) leaves the
|
|
19
|
+
* stack untouched. */
|
|
20
|
+
push(page: Presentable, opts?: NavigateOptions): void | Promise<void>
|
|
21
|
+
replace(page: Presentable, opts?: NavigateOptions): void | Promise<void>
|
|
22
|
+
pop(to?: number, opts?: NavigateOptions): void
|
|
13
23
|
hide(): void
|
|
14
24
|
restore(): void
|
|
15
25
|
|
|
16
|
-
addEventListener(channel: "change", callback: (
|
|
17
|
-
removeEventListener(channel: "change", callback: (
|
|
26
|
+
addEventListener(channel: "change", callback: (page: Presentable) => void): void
|
|
27
|
+
removeEventListener(channel: "change", callback: (page: Presentable) => void): void
|
|
18
28
|
}
|
|
19
29
|
|
|
20
|
-
let
|
|
21
|
-
let onChange =
|
|
22
|
-
|
|
30
|
+
let currentPage: Presentable | null = null
|
|
31
|
+
let onChange = (page: any) => {
|
|
32
|
+
// Hosts hand back the wire object: the screen itself, or a descriptor carrying `_p`.
|
|
33
|
+
currentPage = page && page._p ? page._p : page
|
|
34
|
+
// The router's page is the visible destination (hosts fire this on every stack change,
|
|
35
|
+
// including back gestures) — keep Presentable.current in sync.
|
|
36
|
+
_setCurrent(currentPage)
|
|
23
37
|
for (let callback of (Router as any)._changeListeners) {
|
|
24
|
-
callback(
|
|
38
|
+
callback(currentPage)
|
|
25
39
|
}
|
|
26
40
|
}
|
|
27
41
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
// Non-screen destinations have no legacy bridge to fall back to — surface that clearly instead
|
|
43
|
+
// of letting an old host try to build a screen out of a scene descriptor.
|
|
44
|
+
const wireDesc = (page: Presentable): object => {
|
|
45
|
+
const desc = _descOf(page)
|
|
46
|
+
if (desc !== page && !_navSupported()) {
|
|
47
|
+
throw new Error("This host does not support scene/native-view navigation (requires _creatorUI.openView)")
|
|
48
|
+
}
|
|
49
|
+
return desc
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
export const Router: Router = /*#__PURE__*/ Object.freeze({
|
|
34
53
|
|
|
35
|
-
init(homePage:
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
init(homePage: Presentable, opts: RouterOptions = {}): void {
|
|
55
|
+
_bumpNavEpoch()
|
|
56
|
+
_creatorUI.routerOpen(wireDesc(homePage), onChange, opts.showDefaultBackButton ?? false)
|
|
57
|
+
currentPage = homePage
|
|
58
|
+
_setCurrent(homePage)
|
|
38
59
|
},
|
|
39
|
-
push(
|
|
40
|
-
|
|
60
|
+
push(page: Presentable, opts?: NavigateOptions): void | Promise<void> {
|
|
61
|
+
const desc = wireDesc(page) // throws early on unsupported hosts, before any async work
|
|
62
|
+
const go = () => {
|
|
63
|
+
_bumpNavEpoch()
|
|
64
|
+
// Legacy hosts ignore the extra transition argument.
|
|
65
|
+
_creatorUI.routerPush(desc, opts?.transition ?? "push")
|
|
66
|
+
_setCurrent(page) // optimistic — host onChange confirms (async on native hosts)
|
|
67
|
+
}
|
|
68
|
+
// A preparing destination (ARScene) keeps the current page visible until it's ready; a
|
|
69
|
+
// rejection (camera denied / superseded) propagates to the caller and nothing is pushed.
|
|
70
|
+
const prep = page._prepare?.()
|
|
71
|
+
return prep ? prep.then(go) : go()
|
|
41
72
|
},
|
|
42
|
-
replace(
|
|
43
|
-
|
|
73
|
+
replace(page: Presentable, opts?: NavigateOptions): void | Promise<void> {
|
|
74
|
+
const desc = wireDesc(page)
|
|
75
|
+
const go = () => {
|
|
76
|
+
_bumpNavEpoch()
|
|
77
|
+
_creatorUI.routerReplace(desc, opts?.transition ?? "fade")
|
|
78
|
+
_setCurrent(page) // optimistic — host onChange confirms (async on native hosts)
|
|
79
|
+
}
|
|
80
|
+
const prep = page._prepare?.()
|
|
81
|
+
return prep ? prep.then(go) : go()
|
|
44
82
|
},
|
|
45
|
-
pop(to?: number): void {
|
|
46
|
-
|
|
83
|
+
pop(to?: number, opts?: NavigateOptions): void {
|
|
84
|
+
_bumpNavEpoch()
|
|
85
|
+
_creatorUI.routerPop(to ?? -1, opts?.transition ?? "pop")
|
|
47
86
|
},
|
|
48
87
|
hide() {
|
|
88
|
+
_bumpNavEpoch()
|
|
49
89
|
_creatorUI.routerHide()
|
|
90
|
+
// hide() only blanks the router's own page — a directly-opened destination stays visible.
|
|
91
|
+
if (Presentable.current === currentPage) _setCurrent(null)
|
|
50
92
|
},
|
|
51
93
|
restore() {
|
|
94
|
+
_bumpNavEpoch()
|
|
52
95
|
_creatorUI.routerRestore()
|
|
96
|
+
_setCurrent(currentPage) // the stack top is visible again
|
|
53
97
|
},
|
|
54
98
|
|
|
55
|
-
get current():
|
|
56
|
-
return
|
|
99
|
+
get current(): Presentable {
|
|
100
|
+
return currentPage!
|
|
57
101
|
},
|
|
58
|
-
|
|
102
|
+
|
|
59
103
|
_changeListeners: [] as any[],
|
|
60
|
-
addEventListener(channel: "change", callback: (
|
|
104
|
+
addEventListener(channel: "change", callback: (page: Presentable) => void): void {
|
|
61
105
|
if (channel === "change") {
|
|
62
106
|
this._changeListeners.push(callback)
|
|
63
107
|
}
|
|
64
108
|
},
|
|
65
|
-
removeEventListener(channel: "change", callback: (
|
|
109
|
+
removeEventListener(channel: "change", callback: (page: Presentable) => void): void {
|
|
66
110
|
if (channel === "change") {
|
|
67
111
|
let index = this._changeListeners.indexOf(callback)
|
|
68
112
|
if (index >= 0) {
|