@starcried/starlite-core 0.1.0-alpha.1 → 0.1.0-alpha.3
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 +6 -0
- package/dist/dialogue.d.ts +12 -0
- package/dist/dialogue.js +87 -7
- package/dist/hit-mask.d.ts +1 -1
- package/dist/hit-mask.js +2 -2
- package/dist/index.d.ts +18 -1
- package/dist/index.js +3 -1
- package/dist/pass-through.js +3 -0
- package/dist/performance.d.ts +32 -0
- package/dist/performance.js +103 -0
- package/dist/quick-settings.d.ts +51 -0
- package/dist/quick-settings.js +508 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,12 @@ npm install @starcried/starlite-core@alpha
|
|
|
18
18
|
|
|
19
19
|
`DialogueScheduler` 管理首次入场、偶发小动作和点击回应;`DialogueBubble` 管理纯文本、ARIA 状态提示、边缘位置、过期和清理。两个后端共用该组件与样式。气泡可由宿主独立调用,无需模型或动作;VRM 的声明式场景也可省略 `animation`,仅配置 `lines` 显示句子,不打断待机。
|
|
20
20
|
|
|
21
|
+
支持热更新的后端还可提供 `setDisplaySize(width, height)`、`setFrameRate(30 | 60 | "display")`、`setPointerEnabled(boolean)`、`setBubblesVisible(boolean)` 与 `setPerformanceVisible(boolean)`。这些操作不替换模型或重播动作;隐藏气泡只影响文字显示。
|
|
22
|
+
|
|
23
|
+
`setDialogueContext(context | null)` 可临时切换宿主句库;`null` 恢复原句库,不重载模型或重播首次入场。`DialogueContext` 包含 `id`、`lines`、可选 `animation`、`triggers: ["click", "idle"]`、`mode: "replace" | "merge"` 和 `order: "sequential" | "random"`。顺序模式下点击与偶发共用进度,每次触发下一条;相同内容与标识的刷新不重置进度。合并模式先加入上下文句子,再保留全局句子原有的动作绑定。动作名称必须已在模型中加载;省略动作可只显示文字。切换时只清理旧文字,当前动作自然结束;暂停、隐藏及访客的气泡开关保持不变。
|
|
24
|
+
|
|
25
|
+
`QuickSettingsMenu` 是宿主共用的轻量 DOM 设置卡片。宿主提供标签页、开关、范围、选项与动作回调,并负责实际模型命中检测和偏好持久化;`open(x, y)` 使用视口坐标,`update(options)` 保留当前标签页与控件,`close()` / `destroy()` 清理可见和交互状态。通过 `element` 与 `starlite-menu-change` 事件可同步原生窗口交互区域。
|
|
26
|
+
|
|
21
27
|
## 语句与动作绑定
|
|
22
28
|
|
|
23
29
|
```ts
|
package/dist/dialogue.d.ts
CHANGED
|
@@ -11,6 +11,16 @@ export interface DialogueSceneConfig {
|
|
|
11
11
|
export interface ResolvedDialogueScene extends DialogueSceneConfig {
|
|
12
12
|
durationMs: number;
|
|
13
13
|
}
|
|
14
|
+
export interface DialogueContext {
|
|
15
|
+
id: string;
|
|
16
|
+
lines: readonly string[];
|
|
17
|
+
animation?: string;
|
|
18
|
+
triggers: readonly ("click" | "idle")[];
|
|
19
|
+
mode: "replace" | "merge";
|
|
20
|
+
order: "sequential" | "random";
|
|
21
|
+
}
|
|
22
|
+
/** Copy bounded data before a host replaces an active dialogue context. */
|
|
23
|
+
export declare function resolveDialogueContext(input: unknown): DialogueContext;
|
|
14
24
|
/** Validate and copy plain-text libraries at the backend configuration boundary. */
|
|
15
25
|
export declare function resolveDialogueSentences(input: Pick<DialogueSceneConfig, "lines" | "linesByAnimation">, animations: readonly string[], field: string): Pick<DialogueSceneConfig, "lines" | "linesByAnimation">;
|
|
16
26
|
export interface DialogueSchedule {
|
|
@@ -40,6 +50,8 @@ export declare class DialogueScheduler {
|
|
|
40
50
|
#private;
|
|
41
51
|
constructor(config: DialogueSchedule, random: () => number);
|
|
42
52
|
start(timestampMs: number, immediateEnter?: boolean): void;
|
|
53
|
+
/** Preserve the model and first entrance while replacing host-owned text. */
|
|
54
|
+
setContext(input: DialogueContext | null, timestampMs: number): boolean;
|
|
43
55
|
poll(timestampMs: number, foregroundActive: boolean): DialogueAction | undefined;
|
|
44
56
|
triggerClick(timestampMs: number): DialogueAction | undefined;
|
|
45
57
|
/** Postpones natural speech after visibility changes or direct interaction. */
|
package/dist/dialogue.js
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
/** Copy bounded data before a host replaces an active dialogue context. */
|
|
2
|
+
export function resolveDialogueContext(input) {
|
|
3
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
4
|
+
throw new Error("dialogue context must be an object");
|
|
5
|
+
const value = input;
|
|
6
|
+
if (Object.keys(value).some(key => !["id", "lines", "animation", "triggers", "mode", "order"].includes(key)))
|
|
7
|
+
throw new Error("dialogue context has an unknown field");
|
|
8
|
+
const text = (item, maximum, field) => {
|
|
9
|
+
if (typeof item !== "string" || !item.trim() || new TextEncoder().encode(item.trim()).byteLength > maximum)
|
|
10
|
+
throw new Error(`dialogue context ${field} is invalid`);
|
|
11
|
+
return item.trim();
|
|
12
|
+
};
|
|
13
|
+
const id = text(value.id, 2048, "id");
|
|
14
|
+
const lines = resolveDialogueSentences({ lines: value.lines }, [], "dialogue context").lines;
|
|
15
|
+
if (!Array.isArray(value.triggers) || !value.triggers.length || value.triggers.length > 2 || new Set(value.triggers).size !== value.triggers.length || value.triggers.some(trigger => trigger !== "click" && trigger !== "idle"))
|
|
16
|
+
throw new Error("dialogue context triggers are invalid");
|
|
17
|
+
if (value.mode !== "replace" && value.mode !== "merge")
|
|
18
|
+
throw new Error("dialogue context mode is invalid");
|
|
19
|
+
if (value.order !== "sequential" && value.order !== "random")
|
|
20
|
+
throw new Error("dialogue context order is invalid");
|
|
21
|
+
return { id, lines, ...(value.animation !== undefined ? { animation: text(value.animation, 1024, "animation") } : {}),
|
|
22
|
+
triggers: ["click", "idle"].filter(trigger => value.triggers.includes(trigger)), mode: value.mode, order: value.order };
|
|
23
|
+
}
|
|
1
24
|
/** Validate and copy plain-text libraries at the backend configuration boundary. */
|
|
2
25
|
export function resolveDialogueSentences(input, animations, field) {
|
|
3
26
|
const resolve = (value, path) => {
|
|
@@ -47,6 +70,10 @@ export class DialogueScheduler {
|
|
|
47
70
|
#started = false;
|
|
48
71
|
#enterDueAt;
|
|
49
72
|
#nextIdleAt;
|
|
73
|
+
#context = null;
|
|
74
|
+
#contextSignature = "null";
|
|
75
|
+
#contextEntries = [];
|
|
76
|
+
#contextCursor = 0;
|
|
50
77
|
constructor(config, random) {
|
|
51
78
|
this.#config = config;
|
|
52
79
|
this.#random = random;
|
|
@@ -62,6 +89,25 @@ export class DialogueScheduler {
|
|
|
62
89
|
}
|
|
63
90
|
this.#scheduleIdle(timestampMs);
|
|
64
91
|
}
|
|
92
|
+
/** Preserve the model and first entrance while replacing host-owned text. */
|
|
93
|
+
setContext(input, timestampMs) {
|
|
94
|
+
if (!Number.isFinite(timestampMs))
|
|
95
|
+
throw new Error("dialogue context timestamp is invalid");
|
|
96
|
+
const context = input === null ? null : resolveDialogueContext(input);
|
|
97
|
+
const signature = JSON.stringify(context);
|
|
98
|
+
if (signature === this.#contextSignature)
|
|
99
|
+
return false;
|
|
100
|
+
this.#context = context;
|
|
101
|
+
this.#contextSignature = signature;
|
|
102
|
+
this.#contextCursor = 0;
|
|
103
|
+
this.#contextEntries = context ? [
|
|
104
|
+
...context.lines.map(line => ({ line, ...(context.animation !== undefined ? { animation: context.animation } : {}), triggers: context.triggers })),
|
|
105
|
+
...(context.mode === "merge" ? context.triggers.flatMap(scene => this.#entries(scene)) : []),
|
|
106
|
+
] : [];
|
|
107
|
+
if (this.#started)
|
|
108
|
+
this.#scheduleIdle(timestampMs);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
65
111
|
poll(timestampMs, foregroundActive) {
|
|
66
112
|
if (!this.#started || foregroundActive) {
|
|
67
113
|
return undefined;
|
|
@@ -74,10 +120,10 @@ export class DialogueScheduler {
|
|
|
74
120
|
this.#scheduleIdle(timestampMs + action.durationMs);
|
|
75
121
|
return action;
|
|
76
122
|
}
|
|
77
|
-
if (this.#config.idle &&
|
|
123
|
+
if ((this.#config.idle || this.#context?.triggers.includes("idle")) &&
|
|
78
124
|
this.#nextIdleAt !== undefined &&
|
|
79
125
|
timestampMs >= this.#nextIdleAt) {
|
|
80
|
-
const action = this.#action("idle", this.#config.idle);
|
|
126
|
+
const action = this.#contextAction("idle") ?? this.#action("idle", this.#config.idle);
|
|
81
127
|
this.#scheduleIdle(timestampMs + action.durationMs);
|
|
82
128
|
return action;
|
|
83
129
|
}
|
|
@@ -85,10 +131,10 @@ export class DialogueScheduler {
|
|
|
85
131
|
}
|
|
86
132
|
triggerClick(timestampMs) {
|
|
87
133
|
this.deferIdle(timestampMs);
|
|
88
|
-
if (!this.#config.click) {
|
|
134
|
+
if (!this.#config.click && !this.#context?.triggers.includes("click")) {
|
|
89
135
|
return undefined;
|
|
90
136
|
}
|
|
91
|
-
const action = this.#action("click", this.#config.click);
|
|
137
|
+
const action = this.#contextAction("click") ?? this.#action("click", this.#config.click);
|
|
92
138
|
this.#scheduleIdle(timestampMs + action.durationMs);
|
|
93
139
|
return action;
|
|
94
140
|
}
|
|
@@ -98,6 +144,40 @@ export class DialogueScheduler {
|
|
|
98
144
|
this.#scheduleIdle(timestampMs);
|
|
99
145
|
}
|
|
100
146
|
}
|
|
147
|
+
#entries(scene) {
|
|
148
|
+
const config = this.#config[scene];
|
|
149
|
+
if (!config)
|
|
150
|
+
return [];
|
|
151
|
+
const animations = config.animations?.length ? config.animations : config.animation ? [config.animation] : [];
|
|
152
|
+
if (config.linesByAnimation)
|
|
153
|
+
return animations.flatMap(animation => (config.linesByAnimation[animation] ?? []).map(line => ({ line, animation, durationMs: config.durationMs, triggers: [scene] })));
|
|
154
|
+
return (animations.length ? animations : [undefined]).flatMap(animation => (config.lines ?? []).map(line => ({ line, ...(animation !== undefined ? { animation } : {}), durationMs: config.durationMs, triggers: [scene] })));
|
|
155
|
+
}
|
|
156
|
+
#contextAction(scene) {
|
|
157
|
+
const context = this.#context;
|
|
158
|
+
if (!context?.triggers.includes(scene))
|
|
159
|
+
return undefined;
|
|
160
|
+
let entry;
|
|
161
|
+
if (context.order === "random") {
|
|
162
|
+
const eligible = this.#contextEntries.filter(item => item.triggers.includes(scene));
|
|
163
|
+
entry = eligible[Math.floor(this.#unitRandom() * eligible.length)];
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
for (let offset = 0; offset < this.#contextEntries.length; offset++) {
|
|
167
|
+
const index = (this.#contextCursor + offset) % this.#contextEntries.length;
|
|
168
|
+
const item = this.#contextEntries[index];
|
|
169
|
+
if (!item.triggers.includes(scene))
|
|
170
|
+
continue;
|
|
171
|
+
entry = item;
|
|
172
|
+
this.#contextCursor = (index + 1) % this.#contextEntries.length;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!entry)
|
|
177
|
+
return undefined;
|
|
178
|
+
const config = this.#config[scene];
|
|
179
|
+
return { scene, line: entry.line, ...(entry.animation !== undefined ? { animation: entry.animation } : {}), durationMs: entry.durationMs ?? (config ? config.durationMs : 4000) };
|
|
180
|
+
}
|
|
101
181
|
#action(scene, config) {
|
|
102
182
|
// Preserve the historical random draw order for shared sentence libraries.
|
|
103
183
|
// Bound libraries choose the animation first, then only its own sentences.
|
|
@@ -121,12 +201,12 @@ export class DialogueScheduler {
|
|
|
121
201
|
return;
|
|
122
202
|
}
|
|
123
203
|
const idle = this.#config.idle;
|
|
124
|
-
if (!idle) {
|
|
204
|
+
if (!idle && !this.#context?.triggers.includes("idle")) {
|
|
125
205
|
this.#nextIdleAt = undefined;
|
|
126
206
|
return;
|
|
127
207
|
}
|
|
128
|
-
const interval = idle.intervalMinMs +
|
|
129
|
-
(idle.intervalMaxMs - idle.intervalMinMs) * this.#unitRandom();
|
|
208
|
+
const interval = (idle ? idle.intervalMinMs : 30000) +
|
|
209
|
+
((idle ? idle.intervalMaxMs : 60000) - (idle ? idle.intervalMinMs : 30000)) * this.#unitRandom();
|
|
130
210
|
this.#nextIdleAt = timestampMs + interval;
|
|
131
211
|
}
|
|
132
212
|
#unitRandom() {
|
package/dist/hit-mask.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export declare function packAlphaMask(rgba: Uint8ClampedArray): Uint8Array;
|
|
|
16
16
|
export declare class RenderedHitMask {
|
|
17
17
|
#private;
|
|
18
18
|
setListener(listener?: ModelHitMaskListener): void;
|
|
19
|
-
capture(canvas: HTMLCanvasElement, timestamp: number): void;
|
|
19
|
+
capture(canvas: HTMLCanvasElement, timestamp: number, force?: boolean): void;
|
|
20
20
|
clear(): void;
|
|
21
21
|
destroy(): void;
|
|
22
22
|
}
|
package/dist/hit-mask.js
CHANGED
|
@@ -19,8 +19,8 @@ export class RenderedHitMask {
|
|
|
19
19
|
if (!listener)
|
|
20
20
|
this.#surface = undefined;
|
|
21
21
|
}
|
|
22
|
-
capture(canvas, timestamp) {
|
|
23
|
-
if (!this.#listener || timestamp - this.#lastTime < 50)
|
|
22
|
+
capture(canvas, timestamp, force = false) {
|
|
23
|
+
if (!this.#listener || (!force && timestamp - this.#lastTime < 50))
|
|
24
24
|
return;
|
|
25
25
|
this.#lastTime = timestamp;
|
|
26
26
|
const rect = canvas.getBoundingClientRect();
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface ModelAnimationInfo {
|
|
|
10
10
|
durationMs: number;
|
|
11
11
|
}
|
|
12
12
|
import type { ModelHitMaskListener } from "./hit-mask.js";
|
|
13
|
+
import type { DialogueContext } from "./dialogue.js";
|
|
13
14
|
export { RenderedHitMask, packAlphaMask, type ModelHitMask, type ModelHitMaskListener } from "./hit-mask.js";
|
|
14
15
|
export interface ModelController {
|
|
15
16
|
readonly status: WidgetStatus;
|
|
@@ -20,9 +21,23 @@ export interface ModelController {
|
|
|
20
21
|
stopAnimation(): void;
|
|
21
22
|
resetCamera(): void;
|
|
22
23
|
observeInteractionMask(listener?: ModelHitMaskListener): void;
|
|
24
|
+
/** Refresh current alpha coverage after a host resize, including while paused. */
|
|
25
|
+
refreshInteractionMask?(): void;
|
|
23
26
|
setPointerPosition(clientX: number, clientY: number): void;
|
|
24
27
|
/** Optional host-movement capability. Coordinates are CSS pixels, local only. */
|
|
25
28
|
setHostPosition?(x: number, y: number): void;
|
|
29
|
+
/** Toggle local model metrics without remounting; disabled by default. */
|
|
30
|
+
setPerformanceVisible?(visible: boolean): void;
|
|
31
|
+
/** Resize the logical portrait in CSS pixels without resetting playback. */
|
|
32
|
+
setDisplaySize?(width: number, height: number): void;
|
|
33
|
+
/** Change the rendering cap without remounting or advancing hidden time. */
|
|
34
|
+
setFrameRate?(fps: number | "display"): void;
|
|
35
|
+
/** Toggle pointer tracking; disabling eases the gaze back to neutral. */
|
|
36
|
+
setPointerEnabled?(enabled: boolean): void;
|
|
37
|
+
/** Hide dialogue text independently of its scheduled animations. */
|
|
38
|
+
setBubblesVisible?(visible: boolean): void;
|
|
39
|
+
/** Replace bounded host dialogue without remounting or replaying entrance. */
|
|
40
|
+
setDialogueContext?(context: DialogueContext | null): void;
|
|
26
41
|
destroy(): void;
|
|
27
42
|
}
|
|
28
43
|
export interface MountOptions {
|
|
@@ -42,6 +57,8 @@ export declare function mountModelWidget<C extends {
|
|
|
42
57
|
}>>, options?: MountOptions): Promise<ModelController>;
|
|
43
58
|
/** Streaming limit applies to decoded response bytes, not just Content-Length. */
|
|
44
59
|
export declare function fetchBounded(url: string, maximumBytes: number, signal: AbortSignal): Promise<ArrayBuffer>;
|
|
45
|
-
export { DialogueScheduler, resolveDialogueSentences, type DialogueAction, type DialogueSchedule, type DialogueSceneConfig, type ResolvedDialogueScene } from "./dialogue.js";
|
|
60
|
+
export { DialogueScheduler, resolveDialogueSentences, resolveDialogueContext, type DialogueContext, type DialogueAction, type DialogueSchedule, type DialogueSceneConfig, type ResolvedDialogueScene } from "./dialogue.js";
|
|
46
61
|
export { DialogueBubble } from "./bubble.js";
|
|
47
62
|
export { observePassThroughClicks } from "./pass-through.js";
|
|
63
|
+
export { PerformanceOverlay, FrameSamples, validatePerformanceConfig, type PerformanceConfig, type PerformanceFrameDetails } from "./performance.js";
|
|
64
|
+
export { QuickSettingsMenu, type QuickSettingsOptions, type QuickSettingItem, type QuickSettingCallback } from "./quick-settings.js";
|
package/dist/index.js
CHANGED
|
@@ -80,6 +80,8 @@ export async function fetchBounded(url, maximumBytes, signal) {
|
|
|
80
80
|
}
|
|
81
81
|
return bytes.buffer;
|
|
82
82
|
}
|
|
83
|
-
export { DialogueScheduler, resolveDialogueSentences } from "./dialogue.js";
|
|
83
|
+
export { DialogueScheduler, resolveDialogueSentences, resolveDialogueContext } from "./dialogue.js";
|
|
84
84
|
export { DialogueBubble } from "./bubble.js";
|
|
85
85
|
export { observePassThroughClicks } from "./pass-through.js";
|
|
86
|
+
export { PerformanceOverlay, FrameSamples, validatePerformanceConfig } from "./performance.js";
|
|
87
|
+
export { QuickSettingsMenu } from "./quick-settings.js";
|
package/dist/pass-through.js
CHANGED
|
@@ -4,6 +4,9 @@ export function observePassThroughClicks(surface, onClick) {
|
|
|
4
4
|
let down;
|
|
5
5
|
let ready;
|
|
6
6
|
const inside = (event) => {
|
|
7
|
+
// Local companion controls are not clicks on the model beneath them.
|
|
8
|
+
if (event.composedPath().some(target => target instanceof Element && target.matches("[data-starlite-quick-settings],[data-starlite-widget-restore]")))
|
|
9
|
+
return false;
|
|
7
10
|
const rect = surface.getBoundingClientRect();
|
|
8
11
|
return rect.width > 0 && rect.height > 0
|
|
9
12
|
&& event.clientX >= rect.left && event.clientX < rect.right
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The overlay is opt-in; mounting a model with the default config creates none. */
|
|
2
|
+
export interface PerformanceConfig {
|
|
3
|
+
show?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export type PerformanceStatus = "loading" | "running" | "paused" | "fallback" | "disabled" | "destroyed";
|
|
6
|
+
export interface PerformanceFrameDetails {
|
|
7
|
+
calls?: number;
|
|
8
|
+
triangles?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function validatePerformanceConfig(value: unknown): void;
|
|
11
|
+
/** Bounded numeric history; recording does not shift an array every frame. */
|
|
12
|
+
export declare class FrameSamples {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(capacity?: number);
|
|
15
|
+
add(value: number): void;
|
|
16
|
+
reset(): void;
|
|
17
|
+
values(): number[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Local-only, non-interactive overlay. The host calls recordFrame after an
|
|
21
|
+
* actual scheduled render; RAF callbacks skipped by an FPS cap do not count.
|
|
22
|
+
* CPU cost is model update + WebGL submission, not GPU execution or page cost.
|
|
23
|
+
* No polling, timers, memory probes or WebGL readbacks are introduced here.
|
|
24
|
+
*/
|
|
25
|
+
export declare class PerformanceOverlay {
|
|
26
|
+
#private;
|
|
27
|
+
readonly element: HTMLOutputElement;
|
|
28
|
+
constructor(host: HTMLElement);
|
|
29
|
+
setStatus(status: PerformanceStatus): void;
|
|
30
|
+
recordFrame(timestampMs: number, cpuMs: number, details?: PerformanceFrameDetails): void;
|
|
31
|
+
destroy(): void;
|
|
32
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export function validatePerformanceConfig(value) {
|
|
2
|
+
if (value === undefined)
|
|
3
|
+
return;
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
5
|
+
|| Object.keys(value).some(key => key !== "show")
|
|
6
|
+
|| ("show" in value && value.show !== undefined && typeof value.show !== "boolean")) {
|
|
7
|
+
throw new Error("performance must be an object with an optional boolean show");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Bounded numeric history; recording does not shift an array every frame. */
|
|
11
|
+
export class FrameSamples {
|
|
12
|
+
#values;
|
|
13
|
+
#next = 0;
|
|
14
|
+
#size = 0;
|
|
15
|
+
constructor(capacity = 240) {
|
|
16
|
+
if (!Number.isSafeInteger(capacity) || capacity < 1)
|
|
17
|
+
throw new Error("invalid sample capacity");
|
|
18
|
+
this.#values = new Float64Array(capacity);
|
|
19
|
+
}
|
|
20
|
+
add(value) {
|
|
21
|
+
if (!Number.isFinite(value) || value < 0)
|
|
22
|
+
return;
|
|
23
|
+
this.#values[this.#next] = value;
|
|
24
|
+
this.#next = (this.#next + 1) % this.#values.length;
|
|
25
|
+
this.#size = Math.min(this.#size + 1, this.#values.length);
|
|
26
|
+
}
|
|
27
|
+
reset() { this.#next = 0; this.#size = 0; }
|
|
28
|
+
values() {
|
|
29
|
+
return Array.from({ length: this.#size }, (_, i) => this.#values[(this.#next - this.#size + i + this.#values.length) % this.#values.length]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Local-only, non-interactive overlay. The host calls recordFrame after an
|
|
34
|
+
* actual scheduled render; RAF callbacks skipped by an FPS cap do not count.
|
|
35
|
+
* CPU cost is model update + WebGL submission, not GPU execution or page cost.
|
|
36
|
+
* No polling, timers, memory probes or WebGL readbacks are introduced here.
|
|
37
|
+
*/
|
|
38
|
+
export class PerformanceOverlay {
|
|
39
|
+
element;
|
|
40
|
+
#intervals = new FrameSamples();
|
|
41
|
+
#cpu = new FrameSamples();
|
|
42
|
+
#previous;
|
|
43
|
+
#lastPaint;
|
|
44
|
+
#status = "loading";
|
|
45
|
+
#destroyed = false;
|
|
46
|
+
constructor(host) {
|
|
47
|
+
this.element = document.createElement("output");
|
|
48
|
+
this.element.className = "model-widget__performance";
|
|
49
|
+
this.element.setAttribute("data-model-performance", "");
|
|
50
|
+
this.element.setAttribute("aria-label", "StarLite model performance");
|
|
51
|
+
this.element.setAttribute("aria-live", "off");
|
|
52
|
+
this.element.title = "Rendered FPS and frame-interval p95; CPU p95 is the wall time of model update and WebGL submission, including any driver waits, not measured GPU execution or CPU utilization. The latest 240 frames are sampled. Pausing resets measurements.";
|
|
53
|
+
// Use the logical host, not the expanded canvas: its negative offsets must
|
|
54
|
+
// not move the panel offscreen or alter the alpha-based interaction mask.
|
|
55
|
+
Object.assign(this.element.style, {
|
|
56
|
+
position: "absolute", left: "8px", bottom: "8px", zIndex: "2",
|
|
57
|
+
maxWidth: "calc(100% - 16px)", boxSizing: "border-box", padding: "6px 8px",
|
|
58
|
+
borderRadius: "6px", background: "rgba(15, 23, 42, .86)", color: "#f1f5f9",
|
|
59
|
+
font: "11px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace",
|
|
60
|
+
whiteSpace: "pre-line", overflowWrap: "anywhere", pointerEvents: "none",
|
|
61
|
+
userSelect: "none", textAlign: "left",
|
|
62
|
+
});
|
|
63
|
+
host.append(this.element);
|
|
64
|
+
this.#paintStatus();
|
|
65
|
+
}
|
|
66
|
+
setStatus(status) {
|
|
67
|
+
if (this.#destroyed || status === this.#status)
|
|
68
|
+
return;
|
|
69
|
+
this.#status = status;
|
|
70
|
+
this.#intervals.reset();
|
|
71
|
+
this.#cpu.reset();
|
|
72
|
+
this.#previous = undefined;
|
|
73
|
+
this.#lastPaint = undefined;
|
|
74
|
+
this.#paintStatus();
|
|
75
|
+
}
|
|
76
|
+
recordFrame(timestampMs, cpuMs, details = {}) {
|
|
77
|
+
if (this.#destroyed || this.#status !== "running" || !Number.isFinite(timestampMs))
|
|
78
|
+
return;
|
|
79
|
+
if (this.#previous !== undefined && timestampMs > this.#previous)
|
|
80
|
+
this.#intervals.add(timestampMs - this.#previous);
|
|
81
|
+
this.#previous = timestampMs;
|
|
82
|
+
this.#cpu.add(cpuMs);
|
|
83
|
+
if (this.#lastPaint !== undefined && timestampMs - this.#lastPaint < 500)
|
|
84
|
+
return;
|
|
85
|
+
this.#lastPaint = timestampMs;
|
|
86
|
+
const intervals = this.#intervals.values(), cpu = this.#cpu.values();
|
|
87
|
+
const sum = intervals.reduce((total, value) => total + value, 0);
|
|
88
|
+
const fps = sum > 0 ? `${(1000 * intervals.length / sum).toFixed(1)} FPS` : "FPS warming up";
|
|
89
|
+
const p95 = (values) => values.length ? values.sort((a, b) => a - b)[Math.ceil(values.length * .95) - 1].toFixed(1) : "—";
|
|
90
|
+
const lines = [fps, `Frame p95 ${p95(intervals)} ms`, `CPU p95 ${p95(cpu)} ms`];
|
|
91
|
+
if (Number.isFinite(details.calls) && Number.isFinite(details.triangles)) {
|
|
92
|
+
lines.push(`${details.calls} calls · ${details.triangles.toLocaleString("en-US")} tris`);
|
|
93
|
+
}
|
|
94
|
+
this.element.textContent = lines.join("\n");
|
|
95
|
+
this.element.dataset.modelPerformanceStatus = this.#status;
|
|
96
|
+
}
|
|
97
|
+
destroy() { if (this.#destroyed)
|
|
98
|
+
return; this.#destroyed = true; this.element.remove(); }
|
|
99
|
+
#paintStatus() {
|
|
100
|
+
this.element.textContent = `StarLite · ${this.#status}`;
|
|
101
|
+
this.element.dataset.modelPerformanceStatus = this.#status;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type QuickSettingCallback<T> = (value: T) => void | Promise<void>;
|
|
2
|
+
interface QuickSettingBase {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export type QuickSettingItem = (QuickSettingBase & {
|
|
9
|
+
kind: "toggle";
|
|
10
|
+
value: boolean;
|
|
11
|
+
onChange: QuickSettingCallback<boolean>;
|
|
12
|
+
}) | (QuickSettingBase & {
|
|
13
|
+
kind: "range";
|
|
14
|
+
value: number;
|
|
15
|
+
min: number;
|
|
16
|
+
max: number;
|
|
17
|
+
step?: number;
|
|
18
|
+
formatValue?: (value: number) => string;
|
|
19
|
+
onChange: QuickSettingCallback<number>;
|
|
20
|
+
}) | (QuickSettingBase & {
|
|
21
|
+
kind: "select";
|
|
22
|
+
value: string;
|
|
23
|
+
options: readonly {
|
|
24
|
+
value: string;
|
|
25
|
+
label: string;
|
|
26
|
+
}[];
|
|
27
|
+
onChange: QuickSettingCallback<string>;
|
|
28
|
+
}) | (QuickSettingBase & {
|
|
29
|
+
kind: "action";
|
|
30
|
+
onAction: () => void | Promise<void>;
|
|
31
|
+
});
|
|
32
|
+
export interface QuickSettingsOptions {
|
|
33
|
+
title?: string;
|
|
34
|
+
tabs: readonly {
|
|
35
|
+
id: string;
|
|
36
|
+
label: string;
|
|
37
|
+
items: readonly QuickSettingItem[];
|
|
38
|
+
}[];
|
|
39
|
+
}
|
|
40
|
+
/** Shared host controls only; no renderer, model, or application dependencies. */
|
|
41
|
+
export declare class QuickSettingsMenu {
|
|
42
|
+
#private;
|
|
43
|
+
readonly element: HTMLElement;
|
|
44
|
+
constructor(options: QuickSettingsOptions);
|
|
45
|
+
get isOpen(): boolean;
|
|
46
|
+
open(clientX: number, clientY: number): void;
|
|
47
|
+
close(): void;
|
|
48
|
+
update(options: QuickSettingsOptions): void;
|
|
49
|
+
destroy(): void;
|
|
50
|
+
}
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
const STYLE = `
|
|
2
|
+
[data-starlite-menu]{position:fixed;box-sizing:border-box;width:300px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow:auto;padding:14px;border:1px solid #9ba9c740;border-radius:16px;background:#1c2130f5;color:#f0f3ff;box-shadow:0 14px 48px #0005;font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;z-index:2147483647;pointer-events:auto;user-select:none;backdrop-filter:blur(16px);color-scheme:dark;overscroll-behavior:contain}
|
|
3
|
+
[data-starlite-menu][hidden]{display:none!important}
|
|
4
|
+
[data-starlite-menu] *{box-sizing:border-box}
|
|
5
|
+
[data-starlite-menu] button,[data-starlite-menu] input{font:inherit;accent-color:#9faeff}
|
|
6
|
+
[data-starlite-menu] button{color:inherit;background:#ffffff09;border:1px solid #b4bfd528;border-radius:8px;padding:7px 9px}
|
|
7
|
+
[data-starlite-menu] button{cursor:pointer}
|
|
8
|
+
[data-starlite-menu] button:hover{background:#a7b3ff24}
|
|
9
|
+
[data-starlite-menu] :focus-visible{outline:2px solid #b3bfff;outline-offset:2px}
|
|
10
|
+
[data-starlite-menu] :disabled{opacity:.45;cursor:default}
|
|
11
|
+
[data-starlite-menu] .starlite-menu__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;font-weight:650;letter-spacing:.02em}
|
|
12
|
+
[data-starlite-menu] .starlite-menu__close{border:0;background:transparent;padding:2px 7px;font-size:20px;line-height:1.25}
|
|
13
|
+
[data-starlite-menu] [role=tablist]{display:flex;gap:4px;background:#0002;border-radius:9px;padding:3px;margin-bottom:8px}
|
|
14
|
+
[data-starlite-menu] [role=tab]{flex:1;border:0;background:transparent;padding:5px}
|
|
15
|
+
[data-starlite-menu] [role=tab][aria-selected=true]{background:#a7b3ff25;color:#dbe1ff}
|
|
16
|
+
[data-starlite-menu] .starlite-menu__row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 2px;border-bottom:1px solid #b4bfd514;min-height:44px}
|
|
17
|
+
[data-starlite-menu] .starlite-menu__row:last-child{border-bottom:0}
|
|
18
|
+
[data-starlite-menu] .starlite-menu__text{flex:1;min-width:0}
|
|
19
|
+
[data-starlite-menu] .starlite-menu__description{display:block;font-size:12px;color:#b1bbd2;margin-top:2px;font-weight:400}
|
|
20
|
+
[data-starlite-menu] .starlite-menu__toggle{appearance:none;flex:none;width:34px;height:20px;border-radius:12px;background:#69738a;cursor:pointer;position:relative;margin:0}
|
|
21
|
+
[data-starlite-menu] .starlite-menu__toggle:before{content:"";position:absolute;left:3px;top:3px;width:14px;height:14px;border-radius:50%;background:#fff;transition:transform .12s}
|
|
22
|
+
[data-starlite-menu] .starlite-menu__toggle:checked{background:#899cf6}
|
|
23
|
+
[data-starlite-menu] .starlite-menu__toggle:checked:before{transform:translateX(14px)}
|
|
24
|
+
[data-starlite-menu] .starlite-menu__range-row{display:block}
|
|
25
|
+
[data-starlite-menu] .starlite-menu__range-label{display:flex;justify-content:space-between;gap:12px;margin-bottom:8px}
|
|
26
|
+
[data-starlite-menu] input[type=range]{display:block;width:100%;margin:0;cursor:pointer}
|
|
27
|
+
[data-starlite-menu] output{font-variant-numeric:tabular-nums;color:#c8d2ff}
|
|
28
|
+
[data-starlite-menu] .starlite-menu__select-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(90px,150px)}
|
|
29
|
+
[data-starlite-menu] .starlite-menu__select{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;text-align:left;background:#30384f;color:#f0f3ff;border-color:#7d8baa70}
|
|
30
|
+
[data-starlite-menu] .starlite-menu__select:after{content:"⌄";flex:none;color:#c8d2ff;font-size:15px;line-height:1}
|
|
31
|
+
[data-starlite-menu] .starlite-menu__select[aria-expanded=true]{background:#39445f;border-color:#a7b3ff}
|
|
32
|
+
[data-starlite-menu] .starlite-menu__select-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
33
|
+
[data-starlite-menu] .starlite-menu__options{grid-column:1/-1;display:flex;flex-direction:column;gap:3px;margin-top:-4px;padding:5px;border:1px solid #7d8baa70;border-radius:10px;background:#20283b;box-shadow:0 5px 15px #0003}
|
|
34
|
+
[data-starlite-menu] .starlite-menu__options[hidden]{display:none!important}
|
|
35
|
+
[data-starlite-menu] .starlite-menu__option{display:flex;justify-content:space-between;gap:12px;width:100%;border:0;border-radius:6px;background:#20283b;color:#f0f3ff;text-align:left;padding:8px 10px}
|
|
36
|
+
[data-starlite-menu] .starlite-menu__option:after{content:"";width:12px;flex:none}
|
|
37
|
+
[data-starlite-menu] .starlite-menu__option[aria-selected=true]{background:#3a476a;color:#fff}
|
|
38
|
+
[data-starlite-menu] .starlite-menu__option[aria-selected=true]:after{content:"✓";color:#c8d2ff}
|
|
39
|
+
[data-starlite-menu] .starlite-menu__option:hover,[data-starlite-menu] .starlite-menu__option[data-active=true]{background:#4a587e;color:#fff}
|
|
40
|
+
[data-starlite-menu] .starlite-menu__action{display:block;width:100%;text-align:left;border:0;background:transparent;padding:8px 2px}
|
|
41
|
+
[data-starlite-menu] .starlite-menu__status{margin:8px 0 0;color:#ffc7c7;font-size:12px}
|
|
42
|
+
@media(prefers-reduced-motion:reduce){[data-starlite-menu] *{transition:none!important}}
|
|
43
|
+
`;
|
|
44
|
+
let instanceId = 0;
|
|
45
|
+
/** Shared host controls only; no renderer, model, or application dependencies. */
|
|
46
|
+
export class QuickSettingsMenu {
|
|
47
|
+
element = document.createElement("section");
|
|
48
|
+
#options;
|
|
49
|
+
#activeTab;
|
|
50
|
+
#structure = "";
|
|
51
|
+
#controls = new Map();
|
|
52
|
+
#selects = new Map();
|
|
53
|
+
#openSelect;
|
|
54
|
+
#draggingRanges = new Map();
|
|
55
|
+
#outputs = new Map();
|
|
56
|
+
#pending = new Map();
|
|
57
|
+
#generation = 0;
|
|
58
|
+
#destroyed = false;
|
|
59
|
+
#x = 0;
|
|
60
|
+
#y = 0;
|
|
61
|
+
#previousFocus;
|
|
62
|
+
#id = `starlite-menu-${++instanceId}`;
|
|
63
|
+
#outside = (event) => {
|
|
64
|
+
if (!this.isOpen)
|
|
65
|
+
return;
|
|
66
|
+
const path = event.composedPath();
|
|
67
|
+
if (!path.includes(this.element))
|
|
68
|
+
this.close();
|
|
69
|
+
else if (this.#openSelect) {
|
|
70
|
+
const select = this.#selects.get(this.#openSelect);
|
|
71
|
+
if (!path.includes(select.trigger) && !path.includes(select.listbox))
|
|
72
|
+
this.#closeSelect();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
#key = (event) => { if (this.isOpen && event.key === "Escape") {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
this.close();
|
|
78
|
+
} };
|
|
79
|
+
#rangeEnd = (event) => {
|
|
80
|
+
for (const [id, pointerId] of this.#draggingRanges) {
|
|
81
|
+
if (pointerId !== event.pointerId)
|
|
82
|
+
continue;
|
|
83
|
+
this.#draggingRanges.delete(id);
|
|
84
|
+
if (!this.#pending.has(id))
|
|
85
|
+
this.#sync();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
constructor(options) {
|
|
89
|
+
this.#validate(options);
|
|
90
|
+
this.#options = options;
|
|
91
|
+
this.#activeTab = options.tabs[0].id;
|
|
92
|
+
if (!document.querySelector("style[data-starlite-menu-style]")) {
|
|
93
|
+
const style = document.createElement("style");
|
|
94
|
+
style.dataset.starliteMenuStyle = "";
|
|
95
|
+
style.textContent = STYLE;
|
|
96
|
+
document.head.append(style);
|
|
97
|
+
}
|
|
98
|
+
this.element.dataset.starliteMenu = "";
|
|
99
|
+
this.element.dataset.starliteQuickSettings = "";
|
|
100
|
+
this.element.hidden = true;
|
|
101
|
+
this.element.setAttribute("role", "dialog");
|
|
102
|
+
this.element.setAttribute("aria-label", options.title ?? "StarLite");
|
|
103
|
+
this.element.addEventListener("contextmenu", event => event.preventDefault());
|
|
104
|
+
document.body.append(this.element);
|
|
105
|
+
document.addEventListener("pointerdown", this.#outside, true);
|
|
106
|
+
document.addEventListener("keydown", this.#key);
|
|
107
|
+
document.addEventListener("pointerup", this.#rangeEnd, true);
|
|
108
|
+
document.addEventListener("pointercancel", this.#rangeEnd, true);
|
|
109
|
+
this.#render();
|
|
110
|
+
}
|
|
111
|
+
get isOpen() { return !this.#destroyed && !this.element.hidden; }
|
|
112
|
+
open(clientX, clientY) {
|
|
113
|
+
if (this.#destroyed || !Number.isFinite(clientX) || !Number.isFinite(clientY))
|
|
114
|
+
return;
|
|
115
|
+
if (!this.isOpen)
|
|
116
|
+
this.#previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
|
|
117
|
+
this.#closeSelect();
|
|
118
|
+
this.#x = clientX;
|
|
119
|
+
this.#y = clientY;
|
|
120
|
+
this.element.hidden = false;
|
|
121
|
+
this.#position();
|
|
122
|
+
this.element.querySelector("[role=tab][aria-selected=true]")?.focus({ preventScroll: true });
|
|
123
|
+
this.element.dispatchEvent(new Event("starlite-menu-change"));
|
|
124
|
+
}
|
|
125
|
+
close() {
|
|
126
|
+
if (!this.isOpen)
|
|
127
|
+
return;
|
|
128
|
+
const containedFocus = this.element.contains(document.activeElement);
|
|
129
|
+
this.#closeSelect();
|
|
130
|
+
this.#draggingRanges.clear();
|
|
131
|
+
this.element.hidden = true;
|
|
132
|
+
if (containedFocus && this.#previousFocus?.isConnected)
|
|
133
|
+
this.#previousFocus.focus({ preventScroll: true });
|
|
134
|
+
this.element.dispatchEvent(new Event("starlite-menu-change"));
|
|
135
|
+
}
|
|
136
|
+
update(options) {
|
|
137
|
+
if (this.#destroyed)
|
|
138
|
+
return;
|
|
139
|
+
this.#validate(options);
|
|
140
|
+
this.#options = options;
|
|
141
|
+
if (!options.tabs.some(tab => tab.id === this.#activeTab))
|
|
142
|
+
this.#activeTab = options.tabs[0].id;
|
|
143
|
+
this.#render();
|
|
144
|
+
}
|
|
145
|
+
destroy() {
|
|
146
|
+
if (this.#destroyed)
|
|
147
|
+
return;
|
|
148
|
+
this.close();
|
|
149
|
+
this.#destroyed = true;
|
|
150
|
+
this.#generation++;
|
|
151
|
+
document.removeEventListener("pointerdown", this.#outside, true);
|
|
152
|
+
document.removeEventListener("keydown", this.#key);
|
|
153
|
+
document.removeEventListener("pointerup", this.#rangeEnd, true);
|
|
154
|
+
document.removeEventListener("pointercancel", this.#rangeEnd, true);
|
|
155
|
+
this.element.remove();
|
|
156
|
+
this.#controls.clear();
|
|
157
|
+
this.#outputs.clear();
|
|
158
|
+
this.#selects.clear();
|
|
159
|
+
}
|
|
160
|
+
#validate(options) {
|
|
161
|
+
const ids = new Set();
|
|
162
|
+
if (!options.tabs.length)
|
|
163
|
+
throw new Error("quick settings requires a tab");
|
|
164
|
+
for (const tab of options.tabs) {
|
|
165
|
+
if (!tab.id || ids.has(`tab:${tab.id}`))
|
|
166
|
+
throw new Error("quick settings tab IDs must be unique");
|
|
167
|
+
ids.add(`tab:${tab.id}`);
|
|
168
|
+
for (const item of tab.items) {
|
|
169
|
+
if (!item.id || ids.has(item.id))
|
|
170
|
+
throw new Error("quick settings item IDs must be unique");
|
|
171
|
+
ids.add(item.id);
|
|
172
|
+
if (item.kind === "range" && (![item.value, item.min, item.max, item.step ?? 1].every(Number.isFinite) || item.max <= item.min || (item.step ?? 1) <= 0))
|
|
173
|
+
throw new Error("invalid quick settings range");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
#item(id) { return this.#options.tabs.flatMap(tab => tab.items).find(item => item.id === id); }
|
|
178
|
+
#render() {
|
|
179
|
+
const structure = JSON.stringify([this.#activeTab, this.#options.title, this.#options.tabs.map(tab => [tab.id, tab.label, tab.items.map(item => [item.id, item.kind, item.label, item.description, item.kind === "select" ? item.options : item.kind === "range" ? [item.min, item.max, item.step] : null])])]);
|
|
180
|
+
if (structure === this.#structure) {
|
|
181
|
+
this.#sync();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.#closeSelect();
|
|
185
|
+
this.#structure = structure;
|
|
186
|
+
this.#controls.clear();
|
|
187
|
+
this.#outputs.clear();
|
|
188
|
+
this.#selects.clear();
|
|
189
|
+
this.#draggingRanges.clear();
|
|
190
|
+
this.element.replaceChildren();
|
|
191
|
+
const header = document.createElement("div");
|
|
192
|
+
header.className = "starlite-menu__header";
|
|
193
|
+
const title = document.createElement("span");
|
|
194
|
+
title.textContent = this.#options.title ?? "StarLite";
|
|
195
|
+
const close = document.createElement("button");
|
|
196
|
+
close.type = "button";
|
|
197
|
+
close.className = "starlite-menu__close";
|
|
198
|
+
close.textContent = "×";
|
|
199
|
+
close.setAttribute("aria-label", "关闭菜单");
|
|
200
|
+
close.onclick = () => this.close();
|
|
201
|
+
header.append(title, close);
|
|
202
|
+
const tabs = document.createElement("div");
|
|
203
|
+
tabs.setAttribute("role", "tablist");
|
|
204
|
+
tabs.setAttribute("aria-label", "设置分类");
|
|
205
|
+
for (const [index, tab] of this.#options.tabs.entries()) {
|
|
206
|
+
const button = document.createElement("button");
|
|
207
|
+
button.type = "button";
|
|
208
|
+
button.textContent = tab.label;
|
|
209
|
+
button.id = `${this.#id}-tab-${index}`;
|
|
210
|
+
button.setAttribute("role", "tab");
|
|
211
|
+
button.setAttribute("aria-selected", String(tab.id === this.#activeTab));
|
|
212
|
+
button.tabIndex = tab.id === this.#activeTab ? 0 : -1;
|
|
213
|
+
button.setAttribute("aria-controls", `${this.#id}-panel`);
|
|
214
|
+
button.onclick = () => { this.#activeTab = tab.id; this.#render(); this.element.querySelector("[role=tab][aria-selected=true]")?.focus({ preventScroll: true }); };
|
|
215
|
+
button.onkeydown = event => {
|
|
216
|
+
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
|
|
217
|
+
return;
|
|
218
|
+
event.preventDefault();
|
|
219
|
+
const count = this.#options.tabs.length;
|
|
220
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? count - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + count) % count;
|
|
221
|
+
this.#activeTab = this.#options.tabs[next].id;
|
|
222
|
+
this.#render();
|
|
223
|
+
this.element.querySelector("[role=tab][aria-selected=true]")?.focus({ preventScroll: true });
|
|
224
|
+
};
|
|
225
|
+
tabs.append(button);
|
|
226
|
+
}
|
|
227
|
+
const panel = document.createElement("div");
|
|
228
|
+
panel.id = `${this.#id}-panel`;
|
|
229
|
+
panel.setAttribute("role", "tabpanel");
|
|
230
|
+
panel.setAttribute("aria-labelledby", `${this.#id}-tab-${this.#options.tabs.findIndex(tab => tab.id === this.#activeTab)}`);
|
|
231
|
+
for (const item of this.#options.tabs.find(tab => tab.id === this.#activeTab).items)
|
|
232
|
+
panel.append(this.#row(item));
|
|
233
|
+
const status = document.createElement("p");
|
|
234
|
+
status.className = "starlite-menu__status";
|
|
235
|
+
status.setAttribute("role", "status");
|
|
236
|
+
status.hidden = true;
|
|
237
|
+
this.element.append(header, tabs, panel, status);
|
|
238
|
+
this.element.setAttribute("aria-label", this.#options.title ?? "StarLite");
|
|
239
|
+
this.#sync();
|
|
240
|
+
}
|
|
241
|
+
#text(item) {
|
|
242
|
+
const text = document.createElement("span");
|
|
243
|
+
text.className = "starlite-menu__text";
|
|
244
|
+
text.textContent = item.label;
|
|
245
|
+
if (item.description) {
|
|
246
|
+
const description = document.createElement("span");
|
|
247
|
+
description.className = "starlite-menu__description";
|
|
248
|
+
description.textContent = item.description;
|
|
249
|
+
text.append(description);
|
|
250
|
+
}
|
|
251
|
+
return text;
|
|
252
|
+
}
|
|
253
|
+
#row(item) {
|
|
254
|
+
if (item.kind === "action") {
|
|
255
|
+
const row = document.createElement("div");
|
|
256
|
+
row.className = "starlite-menu__row";
|
|
257
|
+
const button = document.createElement("button");
|
|
258
|
+
button.type = "button";
|
|
259
|
+
button.className = "starlite-menu__action";
|
|
260
|
+
button.append(this.#text(item));
|
|
261
|
+
button.dataset.settingId = item.id;
|
|
262
|
+
button.onclick = () => { const current = this.#item(item.id); if (current?.kind === "action")
|
|
263
|
+
this.#invoke(current.id, current.onAction); };
|
|
264
|
+
this.#controls.set(item.id, button);
|
|
265
|
+
row.append(button);
|
|
266
|
+
return row;
|
|
267
|
+
}
|
|
268
|
+
const row = document.createElement(item.kind === "select" ? "div" : "label");
|
|
269
|
+
row.className = "starlite-menu__row";
|
|
270
|
+
const text = this.#text(item);
|
|
271
|
+
if (item.kind === "select") {
|
|
272
|
+
row.classList.add("starlite-menu__select-row");
|
|
273
|
+
const trigger = document.createElement("button");
|
|
274
|
+
trigger.type = "button";
|
|
275
|
+
trigger.dataset.settingId = item.id;
|
|
276
|
+
trigger.className = "starlite-menu__select";
|
|
277
|
+
const index = this.#selects.size;
|
|
278
|
+
text.id = `${this.#id}-select-label-${index}`;
|
|
279
|
+
trigger.setAttribute("role", "combobox");
|
|
280
|
+
trigger.setAttribute("aria-haspopup", "listbox");
|
|
281
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
282
|
+
trigger.setAttribute("aria-labelledby", text.id);
|
|
283
|
+
const label = document.createElement("span");
|
|
284
|
+
label.className = "starlite-menu__select-value";
|
|
285
|
+
trigger.append(label);
|
|
286
|
+
const listbox = document.createElement("div");
|
|
287
|
+
listbox.id = `${this.#id}-select-options-${index}`;
|
|
288
|
+
listbox.className = "starlite-menu__options";
|
|
289
|
+
listbox.hidden = true;
|
|
290
|
+
listbox.setAttribute("role", "listbox");
|
|
291
|
+
listbox.setAttribute("aria-labelledby", text.id);
|
|
292
|
+
trigger.setAttribute("aria-controls", listbox.id);
|
|
293
|
+
const options = item.options.map((option, optionIndex) => {
|
|
294
|
+
const node = document.createElement("button");
|
|
295
|
+
node.type = "button";
|
|
296
|
+
node.tabIndex = -1;
|
|
297
|
+
node.className = "starlite-menu__option";
|
|
298
|
+
node.dataset.value = option.value;
|
|
299
|
+
node.id = `${listbox.id}-${optionIndex}`;
|
|
300
|
+
node.textContent = option.label;
|
|
301
|
+
node.setAttribute("role", "option");
|
|
302
|
+
node.onpointerdown = event => event.preventDefault();
|
|
303
|
+
node.onclick = () => this.#chooseSelect(item.id, optionIndex);
|
|
304
|
+
listbox.append(node);
|
|
305
|
+
return node;
|
|
306
|
+
});
|
|
307
|
+
trigger.onclick = () => this.#openSelect === item.id ? this.#closeSelect() : this.#showSelect(item.id);
|
|
308
|
+
trigger.onkeydown = event => this.#selectKey(item.id, event);
|
|
309
|
+
trigger.onblur = () => { if (this.#openSelect === item.id)
|
|
310
|
+
this.#closeSelect(); };
|
|
311
|
+
this.#selects.set(item.id, { trigger, label, listbox, options, activeIndex: -1 });
|
|
312
|
+
this.#controls.set(item.id, trigger);
|
|
313
|
+
row.append(text, trigger, listbox);
|
|
314
|
+
return row;
|
|
315
|
+
}
|
|
316
|
+
const input = document.createElement("input");
|
|
317
|
+
input.dataset.settingId = item.id;
|
|
318
|
+
if (item.kind === "toggle") {
|
|
319
|
+
input.type = "checkbox";
|
|
320
|
+
input.className = "starlite-menu__toggle";
|
|
321
|
+
input.onchange = () => { const current = this.#item(item.id); if (current?.kind === "toggle")
|
|
322
|
+
this.#invoke(current.id, () => current.onChange(input.checked)); };
|
|
323
|
+
row.append(text, input);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
input.type = "range";
|
|
327
|
+
input.min = String(item.min);
|
|
328
|
+
input.max = String(item.max);
|
|
329
|
+
input.step = String(item.step ?? 1);
|
|
330
|
+
input.onpointerdown = event => { if (event.button === 0 && !input.disabled)
|
|
331
|
+
this.#draggingRanges.set(item.id, event.pointerId); };
|
|
332
|
+
row.classList.add("starlite-menu__range-row");
|
|
333
|
+
const label = document.createElement("span");
|
|
334
|
+
label.className = "starlite-menu__range-label";
|
|
335
|
+
const output = document.createElement("output");
|
|
336
|
+
this.#outputs.set(item.id, output);
|
|
337
|
+
label.append(text, output);
|
|
338
|
+
input.oninput = () => { const current = this.#item(item.id); if (current?.kind === "range") {
|
|
339
|
+
const value = Number(input.value);
|
|
340
|
+
output.textContent = current.formatValue?.(value) ?? String(value);
|
|
341
|
+
this.#invoke(current.id, () => current.onChange(value));
|
|
342
|
+
} };
|
|
343
|
+
row.append(label, input);
|
|
344
|
+
}
|
|
345
|
+
this.#controls.set(item.id, input);
|
|
346
|
+
return row;
|
|
347
|
+
}
|
|
348
|
+
#sync() {
|
|
349
|
+
for (const [id, control] of this.#controls) {
|
|
350
|
+
const item = this.#item(id);
|
|
351
|
+
if (!item)
|
|
352
|
+
continue;
|
|
353
|
+
// Sliders remain usable while host persistence is pending.
|
|
354
|
+
control.disabled = item.disabled === true || (item.kind !== "range" && this.#pending.has(id));
|
|
355
|
+
if (item.kind === "toggle")
|
|
356
|
+
control.checked = item.value;
|
|
357
|
+
else if (item.kind !== "action") {
|
|
358
|
+
// Persisted values may arrive behind an ongoing drag. Keep its thumb
|
|
359
|
+
// under the pointer; the next authoritative update will settle it.
|
|
360
|
+
if (item.kind === "range" && this.#draggingRanges.has(id))
|
|
361
|
+
continue;
|
|
362
|
+
control.value = String(item.value);
|
|
363
|
+
if (item.kind === "range")
|
|
364
|
+
this.#outputs.get(id).textContent = item.formatValue?.(item.value) ?? String(item.value);
|
|
365
|
+
else
|
|
366
|
+
this.#syncSelect(id, item.value);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
#syncSelect(id, value) {
|
|
371
|
+
const select = this.#selects.get(id);
|
|
372
|
+
if (!select)
|
|
373
|
+
return;
|
|
374
|
+
const option = select.options.find(node => node.dataset.value === value);
|
|
375
|
+
select.label.textContent = option?.textContent ?? "—";
|
|
376
|
+
for (const node of select.options)
|
|
377
|
+
node.setAttribute("aria-selected", String(node === option));
|
|
378
|
+
if (select.trigger.disabled && this.#openSelect === id)
|
|
379
|
+
this.#closeSelect();
|
|
380
|
+
}
|
|
381
|
+
#showSelect(id, index) {
|
|
382
|
+
const select = this.#selects.get(id);
|
|
383
|
+
if (!select || select.trigger.disabled || !select.options.length)
|
|
384
|
+
return;
|
|
385
|
+
this.#closeSelect();
|
|
386
|
+
this.#openSelect = id;
|
|
387
|
+
select.listbox.hidden = false;
|
|
388
|
+
select.trigger.setAttribute("aria-expanded", "true");
|
|
389
|
+
this.#activateOption(id, index ?? Math.max(0, select.options.findIndex(node => node.dataset.value === select.trigger.value)));
|
|
390
|
+
this.element.dispatchEvent(new Event("starlite-menu-change"));
|
|
391
|
+
}
|
|
392
|
+
#activateOption(id, index) {
|
|
393
|
+
const select = this.#selects.get(id);
|
|
394
|
+
if (!select)
|
|
395
|
+
return;
|
|
396
|
+
select.activeIndex = Math.max(0, Math.min(index, select.options.length - 1));
|
|
397
|
+
for (const [optionIndex, node] of select.options.entries())
|
|
398
|
+
node.dataset.active = String(optionIndex === select.activeIndex);
|
|
399
|
+
const active = select.options[select.activeIndex];
|
|
400
|
+
if (active) {
|
|
401
|
+
select.trigger.setAttribute("aria-activedescendant", active.id);
|
|
402
|
+
active.scrollIntoView({ block: "nearest" });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
#closeSelect() {
|
|
406
|
+
if (!this.#openSelect)
|
|
407
|
+
return;
|
|
408
|
+
const select = this.#selects.get(this.#openSelect);
|
|
409
|
+
this.#openSelect = undefined;
|
|
410
|
+
if (select) {
|
|
411
|
+
select.listbox.hidden = true;
|
|
412
|
+
select.trigger.setAttribute("aria-expanded", "false");
|
|
413
|
+
select.trigger.removeAttribute("aria-activedescendant");
|
|
414
|
+
for (const node of select.options)
|
|
415
|
+
delete node.dataset.active;
|
|
416
|
+
}
|
|
417
|
+
this.element.dispatchEvent(new Event("starlite-menu-change"));
|
|
418
|
+
}
|
|
419
|
+
#chooseSelect(id, index) {
|
|
420
|
+
const select = this.#selects.get(id), current = this.#item(id), option = select?.options[index];
|
|
421
|
+
if (!select || !option || select.trigger.disabled || current?.kind !== "select")
|
|
422
|
+
return;
|
|
423
|
+
const value = option.dataset.value;
|
|
424
|
+
select.trigger.value = value;
|
|
425
|
+
this.#syncSelect(id, value);
|
|
426
|
+
this.#closeSelect();
|
|
427
|
+
select.trigger.focus({ preventScroll: true });
|
|
428
|
+
this.#invoke(id, () => current.onChange(value));
|
|
429
|
+
}
|
|
430
|
+
#selectKey(id, event) {
|
|
431
|
+
const select = this.#selects.get(id);
|
|
432
|
+
if (!select)
|
|
433
|
+
return;
|
|
434
|
+
if (event.key === "Escape" && this.#openSelect === id) {
|
|
435
|
+
event.preventDefault();
|
|
436
|
+
event.stopPropagation();
|
|
437
|
+
this.#closeSelect();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (event.key === "Tab") {
|
|
441
|
+
this.#closeSelect();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End", "Enter", " "].includes(event.key))
|
|
445
|
+
return;
|
|
446
|
+
event.preventDefault();
|
|
447
|
+
if (event.key === "Home" || event.key === "End") {
|
|
448
|
+
const index = event.key === "Home" ? 0 : select.options.length - 1;
|
|
449
|
+
if (this.#openSelect !== id)
|
|
450
|
+
this.#showSelect(id, index);
|
|
451
|
+
else
|
|
452
|
+
this.#activateOption(id, index);
|
|
453
|
+
}
|
|
454
|
+
else if (this.#openSelect !== id)
|
|
455
|
+
this.#showSelect(id);
|
|
456
|
+
else if (event.key === "Enter" || event.key === " ")
|
|
457
|
+
this.#chooseSelect(id, select.activeIndex);
|
|
458
|
+
else
|
|
459
|
+
this.#activateOption(id, (select.activeIndex + (event.key === "ArrowDown" ? 1 : -1) + select.options.length) % select.options.length);
|
|
460
|
+
}
|
|
461
|
+
#invoke(id, callback) {
|
|
462
|
+
const token = ++this.#generation;
|
|
463
|
+
this.#pending.set(id, token);
|
|
464
|
+
const status = this.element.querySelector("[role=status]");
|
|
465
|
+
status.hidden = true;
|
|
466
|
+
try {
|
|
467
|
+
const result = callback();
|
|
468
|
+
if (result && typeof result.then === "function") {
|
|
469
|
+
// Do not reset a just-edited input before the host returns its new state.
|
|
470
|
+
result.catch(() => { this.#reportError(); this.#sync(); }).finally(() => this.#finish(id, token));
|
|
471
|
+
}
|
|
472
|
+
else
|
|
473
|
+
this.#finish(id, token);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
this.#pending.delete(id);
|
|
477
|
+
this.#reportError();
|
|
478
|
+
this.#sync();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
#finish(id, token) {
|
|
482
|
+
if (this.#pending.get(id) !== token)
|
|
483
|
+
return;
|
|
484
|
+
this.#pending.delete(id);
|
|
485
|
+
const control = this.#controls.get(id);
|
|
486
|
+
if (control)
|
|
487
|
+
control.disabled = this.#item(id)?.disabled === true;
|
|
488
|
+
}
|
|
489
|
+
#reportError() {
|
|
490
|
+
if (this.#destroyed)
|
|
491
|
+
return;
|
|
492
|
+
const status = this.element.querySelector("[role=status]");
|
|
493
|
+
status.textContent = "未能应用设置,请重试。";
|
|
494
|
+
status.hidden = false;
|
|
495
|
+
}
|
|
496
|
+
#position() {
|
|
497
|
+
// Position once per explicit open. Host/model resizes and expanded controls
|
|
498
|
+
// must never move a slider out from under an active pointer.
|
|
499
|
+
this.element.style.maxWidth = "";
|
|
500
|
+
this.element.style.maxHeight = "";
|
|
501
|
+
const rect = this.element.getBoundingClientRect();
|
|
502
|
+
const width = rect.width || Math.min(300, innerWidth - 16), height = rect.height || this.element.offsetHeight;
|
|
503
|
+
this.element.style.left = `${Math.max(8, Math.min(this.#x, innerWidth - width - 8))}px`;
|
|
504
|
+
const top = Math.max(8, Math.min(this.#y, innerHeight - height - 8));
|
|
505
|
+
this.element.style.top = `${top}px`;
|
|
506
|
+
this.element.style.maxHeight = `calc(100vh - ${top + 8}px)`;
|
|
507
|
+
}
|
|
508
|
+
}
|