@starcried/starlite-core 0.1.0-alpha.2 → 0.1.0
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.en.md +45 -0
- package/README.md +9 -1
- package/dist/bubble.d.ts +6 -1
- package/dist/bubble.js +18 -3
- package/dist/dialogue.d.ts +12 -0
- package/dist/dialogue.js +87 -7
- package/dist/environment-lighting.d.ts +9 -0
- package/dist/environment-lighting.js +11 -0
- package/dist/hit-mask.d.ts +1 -1
- package/dist/hit-mask.js +2 -2
- package/dist/index.d.ts +27 -2
- package/dist/index.js +5 -1
- package/dist/locale.d.ts +5 -0
- package/dist/locale.js +12 -0
- 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 +53 -0
- package/dist/quick-settings.js +510 -0
- package/package.json +3 -2
package/README.en.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @starcried/starlite-core
|
|
2
|
+
|
|
3
|
+
[简体中文](README.md) · English
|
|
4
|
+
|
|
5
|
+
Format-neutral playback, lifecycle, dialogue, and host controls for StarLite. This package imports neither Three.js nor Inochi2D WASM. Usually install a backend; it installs core automatically.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @starcried/starlite-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Shared contracts
|
|
12
|
+
|
|
13
|
+
`ModelController` provides `pause`, `resume`, `getAnimations`, `playAnimation`, `stopAnimation`, `resetCamera`, and `destroy`. Playback resolves as `completed`, `interrupted`, or `stopped`; loops resolve when interrupted or stopped. `ModelMountSlot` cancels replaced loads and destroys late instances. Backend-specific bones, tracks, and parameters stay in their backend.
|
|
14
|
+
|
|
15
|
+
`DialogueScheduler` handles entrance, occasional interaction, and clicks. `DialogueBubble` provides plain text, ARIA announcements, placement, expiry, and cleanup. Both backends share these components and styling. A host can use bubbles without a model or motion; VRM dialogue can omit `animation` and specify only `lines`.
|
|
16
|
+
|
|
17
|
+
Optional controller methods include `setDisplaySize`, `setFrameRate`, `setPointerEnabled`, `setBubblesVisible`, and `setPerformanceVisible`. These do not remount the model; hiding bubbles does not stop scheduled motions.
|
|
18
|
+
|
|
19
|
+
`setDialogueContext(context | null)` temporarily replaces or merges a host library. Context contains `id`, `lines`, optional `animation`, `triggers`, `mode`, and `order`. Sequential click and occasional events share progress. Restoring `null` returns to the original library without remounting or entrance replay. Identical context updates preserve progress.
|
|
20
|
+
|
|
21
|
+
## Shared settings menu
|
|
22
|
+
|
|
23
|
+
`QuickSettingsMenu` receives `locale: "en" | "zh-CN"`, tabs, toggles, ranges, selects, and action callbacks. The host owns model hit testing and persistence. `open(x,y)` uses viewport coordinates; `update(options)` preserves selected tab and stable menu position; `close()` and `destroy()` clean up. Observe `starlite-menu-change` through `element` to synchronize a native input region.
|
|
24
|
+
|
|
25
|
+
`resolveLocale(hostLanguage)` maps Chinese tags to `zh-CN`, other nonempty tags to `en`, and absent input to `zh-CN`. Locale affects built-in menu labels and errors only; caller-provided labels and dialogue remain untouched.
|
|
26
|
+
|
|
27
|
+
## Motion-bound dialogue
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
dialogue: {
|
|
31
|
+
click: {
|
|
32
|
+
animations: ["Wave", "Nod"],
|
|
33
|
+
linesByAnimation: {
|
|
34
|
+
Wave: ["Hello!", "Nice to see you."],
|
|
35
|
+
Nod: ["I'm here.", "Go ahead."]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Each event chooses a motion, then a matching sentence. Names match exactly and are case-sensitive. Pools accept 1–20 unique names; `linesByAnimation` must cover the full pool and contain no unrelated names. Use `lines` for a shared sentence library.
|
|
42
|
+
|
|
43
|
+
[Configuration](https://github.com/StarCried/starlite/blob/main/docs/configuration.en.md) · [VRM backend](https://github.com/StarCried/starlite/blob/main/packages/vrm/README.en.md)
|
|
44
|
+
|
|
45
|
+
BSD-2-Clause. No model artwork is included.
|
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# @starcried/starlite-core
|
|
2
2
|
|
|
3
|
+
简体中文 · [English](README.en.md)
|
|
4
|
+
|
|
3
5
|
StarLite 的公共播放契约、生命周期和气泡组件。该包不导入 Three.js 或 Inochi2D WASM;宿主先选择后端,再加载对应运行时。
|
|
4
6
|
|
|
5
7
|
在 Node.js 22.12+ 项目中安装 alpha 版本:
|
|
6
8
|
|
|
7
9
|
```sh
|
|
8
|
-
npm install @starcried/starlite-core
|
|
10
|
+
npm install @starcried/starlite-core
|
|
9
11
|
```
|
|
10
12
|
|
|
11
13
|
通常直接安装所需后端,它会自动安装本包。
|
|
@@ -18,6 +20,12 @@ npm install @starcried/starlite-core@alpha
|
|
|
18
20
|
|
|
19
21
|
`DialogueScheduler` 管理首次入场、偶发小动作和点击回应;`DialogueBubble` 管理纯文本、ARIA 状态提示、边缘位置、过期和清理。两个后端共用该组件与样式。气泡可由宿主独立调用,无需模型或动作;VRM 的声明式场景也可省略 `animation`,仅配置 `lines` 显示句子,不打断待机。
|
|
20
22
|
|
|
23
|
+
支持热更新的后端还可提供 `setDisplaySize(width, height)`、`setFrameRate(30 | 60 | "display")`、`setPointerEnabled(boolean)`、`setBubblesVisible(boolean)` 与 `setPerformanceVisible(boolean)`。这些操作不替换模型或重播动作;隐藏气泡只影响文字显示。
|
|
24
|
+
|
|
25
|
+
`setDialogueContext(context | null)` 可临时切换宿主句库;`null` 恢复原句库,不重载模型或重播首次入场。`DialogueContext` 包含 `id`、`lines`、可选 `animation`、`triggers: ["click", "idle"]`、`mode: "replace" | "merge"` 和 `order: "sequential" | "random"`。顺序模式下点击与偶发共用进度,每次触发下一条;相同内容与标识的刷新不重置进度。合并模式先加入上下文句子,再保留全局句子原有的动作绑定。动作名称必须已在模型中加载;省略动作可只显示文字。切换时只清理旧文字,当前动作自然结束;暂停、隐藏及访客的气泡开关保持不变。
|
|
26
|
+
|
|
27
|
+
`QuickSettingsMenu` 是宿主共用的轻量 DOM 设置卡片。宿主提供标签页、开关、范围、选项与动作回调,并负责实际模型命中检测和偏好持久化;`open(x, y)` 使用视口坐标,`update(options)` 保留当前标签页与控件,`close()` / `destroy()` 清理可见和交互状态。通过 `element` 与 `starlite-menu-change` 事件可同步原生窗口交互区域。
|
|
28
|
+
|
|
21
29
|
## 语句与动作绑定
|
|
22
30
|
|
|
23
31
|
```ts
|
package/dist/bubble.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import type { DialogueAction } from "./dialogue.js";
|
|
2
|
+
/** Stable model top in host-local CSS pixels, independent of canvas padding/DPR. */
|
|
3
|
+
export interface ModelBubbleAnchor {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
}
|
|
2
7
|
/** Plain-text, accessible dialogue presentation shared by every renderer. */
|
|
3
8
|
export declare class DialogueBubble {
|
|
4
9
|
#private;
|
|
5
10
|
readonly element: HTMLDivElement;
|
|
6
|
-
constructor(host: HTMLElement);
|
|
11
|
+
constructor(host: HTMLElement, anchor?: () => ModelBubbleAnchor | undefined);
|
|
7
12
|
show(action: DialogueAction, timestamp: number, withoutFrames?: boolean): void;
|
|
8
13
|
update(timestamp: number): void;
|
|
9
14
|
hide(): void;
|
package/dist/bubble.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
export class DialogueBubble {
|
|
3
3
|
element;
|
|
4
4
|
#host;
|
|
5
|
+
#anchor;
|
|
5
6
|
#until = 0;
|
|
6
7
|
#timeout;
|
|
7
|
-
constructor(host) {
|
|
8
|
+
constructor(host, anchor) {
|
|
8
9
|
this.#host = host;
|
|
10
|
+
this.#anchor = anchor;
|
|
9
11
|
this.element = document.createElement("div");
|
|
10
12
|
this.element.className = "model-widget__bubble";
|
|
11
13
|
this.element.hidden = true;
|
|
@@ -47,8 +49,21 @@ export class DialogueBubble {
|
|
|
47
49
|
return;
|
|
48
50
|
const host = this.#host.getBoundingClientRect(), bubble = this.element.getBoundingClientRect();
|
|
49
51
|
const width = Math.max(1, document.documentElement.clientWidth || window.innerWidth);
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
+
const candidate = this.#anchor?.();
|
|
53
|
+
const anchor = candidate && Number.isFinite(candidate.x) && Number.isFinite(candidate.y) ? candidate : undefined;
|
|
54
|
+
const margin = 12, centered = host.left + (anchor?.x ?? host.width / 2) - bubble.width / 2;
|
|
55
|
+
const above = host.top + (anchor?.y ?? 0) >= bubble.height + margin * 2;
|
|
56
|
+
this.element.dataset.modelPlacement = above ? "above" : "below";
|
|
57
|
+
if (anchor) {
|
|
58
|
+
const height = Math.max(1, document.documentElement.clientHeight || window.innerHeight);
|
|
59
|
+
const preferredTop = above ? host.top + anchor.y - margin - bubble.height : host.top + host.height + margin;
|
|
60
|
+
const top = Math.max(margin, Math.min(Math.max(margin, height - bubble.height - margin), preferredTop));
|
|
61
|
+
Object.assign(this.element.style, { left: `${anchor.x}px`, top: `${top - host.top}px`, bottom: "auto" });
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
for (const property of ["left", "top", "bottom"])
|
|
65
|
+
this.element.style.removeProperty(property);
|
|
66
|
+
}
|
|
52
67
|
const left = Math.max(margin, Math.min(Math.max(margin, width - bubble.width - margin), centered));
|
|
53
68
|
this.element.style.setProperty("--model-bubble-shift-x", `${Math.round(left - centered)}px`);
|
|
54
69
|
}
|
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() {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A local RGB lighting hint. Hosts own colour acquisition and permission. */
|
|
2
|
+
export interface EnvironmentLighting {
|
|
3
|
+
color: string;
|
|
4
|
+
/** 0 restores the original lighting; 1 is the default, 2 exaggerates the effect. */
|
|
5
|
+
strength?: number;
|
|
6
|
+
/** Overall light-energy target .8–1.1, blended/amplified by strength. Omit to infer from RGB. */
|
|
7
|
+
brightness?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function validateEnvironmentLighting(input: unknown): asserts input is EnvironmentLighting;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function validateEnvironmentLighting(input) {
|
|
2
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
3
|
+
throw new Error("environmentLighting must be an object");
|
|
4
|
+
const value = input;
|
|
5
|
+
if (Object.keys(value).some(key => key !== "color" && key !== "strength" && key !== "brightness") || typeof value.color !== "string" || !/^#[0-9a-f]{6}$/iu.test(value.color))
|
|
6
|
+
throw new Error("environmentLighting.color must be #RRGGBB");
|
|
7
|
+
if (value.strength !== undefined && (typeof value.strength !== "number" || !Number.isFinite(value.strength) || value.strength < 0 || value.strength > 2))
|
|
8
|
+
throw new Error("environmentLighting.strength must be between 0 and 2");
|
|
9
|
+
if (value.brightness !== undefined && (typeof value.brightness !== "number" || !Number.isFinite(value.brightness) || value.brightness < .8 || value.brightness > 1.1))
|
|
10
|
+
throw new Error("environmentLighting.brightness must be between 0.8 and 1.1");
|
|
11
|
+
}
|
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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { resolveLocale, validateLocale, type StarLiteLocale } from "./locale.js";
|
|
1
2
|
export type ModelBackend = "inochi2d" | "vrm";
|
|
2
3
|
export type WidgetStatus = "disabled" | "loading" | "running" | "paused" | "fallback" | "destroyed";
|
|
3
4
|
export type AnimationPlaybackResult = "completed" | "interrupted" | "stopped";
|
|
@@ -10,6 +11,10 @@ export interface ModelAnimationInfo {
|
|
|
10
11
|
durationMs: number;
|
|
11
12
|
}
|
|
12
13
|
import type { ModelHitMaskListener } from "./hit-mask.js";
|
|
14
|
+
import type { DialogueContext } from "./dialogue.js";
|
|
15
|
+
import type { EnvironmentLighting } from "./environment-lighting.js";
|
|
16
|
+
export { validateEnvironmentLighting, type EnvironmentLighting } from "./environment-lighting.js";
|
|
17
|
+
import type { ModelBubbleAnchor } from "./bubble.js";
|
|
13
18
|
export { RenderedHitMask, packAlphaMask, type ModelHitMask, type ModelHitMaskListener } from "./hit-mask.js";
|
|
14
19
|
export interface ModelController {
|
|
15
20
|
readonly status: WidgetStatus;
|
|
@@ -19,10 +24,28 @@ export interface ModelController {
|
|
|
19
24
|
playAnimation(name: string, options?: AnimationPlaybackOptions): Promise<AnimationPlaybackResult>;
|
|
20
25
|
stopAnimation(): void;
|
|
21
26
|
resetCamera(): void;
|
|
27
|
+
/** Stable reference-pose top, in host-local CSS pixels; no canvas padding/DPR. */
|
|
28
|
+
getBubbleAnchor?(): ModelBubbleAnchor | undefined;
|
|
22
29
|
observeInteractionMask(listener?: ModelHitMaskListener): void;
|
|
30
|
+
/** Refresh current alpha coverage after a host resize, including while paused. */
|
|
31
|
+
refreshInteractionMask?(): void;
|
|
23
32
|
setPointerPosition(clientX: number, clientY: number): void;
|
|
24
33
|
/** Optional host-movement capability. Coordinates are CSS pixels, local only. */
|
|
25
34
|
setHostPosition?(x: number, y: number): void;
|
|
35
|
+
/** Toggle local model metrics without remounting; disabled by default. */
|
|
36
|
+
setPerformanceVisible?(visible: boolean): void;
|
|
37
|
+
/** Resize the logical portrait in CSS pixels without resetting playback. */
|
|
38
|
+
setDisplaySize?(width: number, height: number): void;
|
|
39
|
+
/** Change the rendering cap without remounting or advancing hidden time. */
|
|
40
|
+
setFrameRate?(fps: number | "display"): void;
|
|
41
|
+
/** Toggle pointer tracking; disabling eases the gaze back to neutral. */
|
|
42
|
+
setPointerEnabled?(enabled: boolean): void;
|
|
43
|
+
/** Hide dialogue text independently of its scheduled animations. */
|
|
44
|
+
setBubblesVisible?(visible: boolean): void;
|
|
45
|
+
/** Replace bounded host dialogue without remounting or replaying entrance. */
|
|
46
|
+
setDialogueContext?(context: DialogueContext | null): void;
|
|
47
|
+
/** Optional ambient-light hint. Null restores the original lighting. */
|
|
48
|
+
setEnvironmentLighting?(lighting: EnvironmentLighting | null): void;
|
|
26
49
|
destroy(): void;
|
|
27
50
|
}
|
|
28
51
|
export interface MountOptions {
|
|
@@ -42,6 +65,8 @@ export declare function mountModelWidget<C extends {
|
|
|
42
65
|
}>>, options?: MountOptions): Promise<ModelController>;
|
|
43
66
|
/** Streaming limit applies to decoded response bytes, not just Content-Length. */
|
|
44
67
|
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";
|
|
46
|
-
export { DialogueBubble } from "./bubble.js";
|
|
68
|
+
export { DialogueScheduler, resolveDialogueSentences, resolveDialogueContext, type DialogueContext, type DialogueAction, type DialogueSchedule, type DialogueSceneConfig, type ResolvedDialogueScene } from "./dialogue.js";
|
|
69
|
+
export { DialogueBubble, type ModelBubbleAnchor } from "./bubble.js";
|
|
47
70
|
export { observePassThroughClicks } from "./pass-through.js";
|
|
71
|
+
export { PerformanceOverlay, FrameSamples, validatePerformanceConfig, type PerformanceConfig, type PerformanceFrameDetails } from "./performance.js";
|
|
72
|
+
export { QuickSettingsMenu, type QuickSettingsOptions, type QuickSettingItem, type QuickSettingCallback } from "./quick-settings.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { resolveLocale, validateLocale } from "./locale.js";
|
|
2
|
+
export { validateEnvironmentLighting } from "./environment-lighting.js";
|
|
1
3
|
export { RenderedHitMask, packAlphaMask } from "./hit-mask.js";
|
|
2
4
|
/** Own one mount, including asynchronous work which outlives a replacement. */
|
|
3
5
|
export class ModelMountSlot {
|
|
@@ -80,6 +82,8 @@ export async function fetchBounded(url, maximumBytes, signal) {
|
|
|
80
82
|
}
|
|
81
83
|
return bytes.buffer;
|
|
82
84
|
}
|
|
83
|
-
export { DialogueScheduler, resolveDialogueSentences } from "./dialogue.js";
|
|
85
|
+
export { DialogueScheduler, resolveDialogueSentences, resolveDialogueContext } from "./dialogue.js";
|
|
84
86
|
export { DialogueBubble } from "./bubble.js";
|
|
85
87
|
export { observePassThroughClicks } from "./pass-through.js";
|
|
88
|
+
export { PerformanceOverlay, FrameSamples, validatePerformanceConfig } from "./performance.js";
|
|
89
|
+
export { QuickSettingsMenu } from "./quick-settings.js";
|
package/dist/locale.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Supported interface languages; user content is never translated. */
|
|
2
|
+
export type StarLiteLocale = "zh-CN" | "en";
|
|
3
|
+
/** Accept host language tags (en-US, zh-TW, ...); unsupported languages use English. */
|
|
4
|
+
export declare function resolveLocale(language?: unknown): StarLiteLocale;
|
|
5
|
+
export declare function validateLocale(value: unknown): void;
|
package/dist/locale.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Accept host language tags (en-US, zh-TW, ...); unsupported languages use English. */
|
|
2
|
+
export function resolveLocale(language) {
|
|
3
|
+
const value = Array.isArray(language) ? language[0] : language;
|
|
4
|
+
if (typeof value !== "string" || !value.trim())
|
|
5
|
+
return "zh-CN";
|
|
6
|
+
return /^zh(?:[-_]|$)/i.test(value.trim()) ? "zh-CN" : "en";
|
|
7
|
+
}
|
|
8
|
+
export function validateLocale(value) {
|
|
9
|
+
if (value !== undefined && value !== "zh-CN" && value !== "en") {
|
|
10
|
+
throw new Error("locale must be zh-CN or en");
|
|
11
|
+
}
|
|
12
|
+
}
|
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,53 @@
|
|
|
1
|
+
import { type StarLiteLocale } from "./locale.js";
|
|
2
|
+
export type QuickSettingCallback<T> = (value: T) => void | Promise<void>;
|
|
3
|
+
interface QuickSettingBase {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export type QuickSettingItem = (QuickSettingBase & {
|
|
10
|
+
kind: "toggle";
|
|
11
|
+
value: boolean;
|
|
12
|
+
onChange: QuickSettingCallback<boolean>;
|
|
13
|
+
}) | (QuickSettingBase & {
|
|
14
|
+
kind: "range";
|
|
15
|
+
value: number;
|
|
16
|
+
min: number;
|
|
17
|
+
max: number;
|
|
18
|
+
step?: number;
|
|
19
|
+
formatValue?: (value: number) => string;
|
|
20
|
+
onChange: QuickSettingCallback<number>;
|
|
21
|
+
}) | (QuickSettingBase & {
|
|
22
|
+
kind: "select";
|
|
23
|
+
value: string;
|
|
24
|
+
options: readonly {
|
|
25
|
+
value: string;
|
|
26
|
+
label: string;
|
|
27
|
+
}[];
|
|
28
|
+
onChange: QuickSettingCallback<string>;
|
|
29
|
+
}) | (QuickSettingBase & {
|
|
30
|
+
kind: "action";
|
|
31
|
+
onAction: () => void | Promise<void>;
|
|
32
|
+
});
|
|
33
|
+
export interface QuickSettingsOptions {
|
|
34
|
+
locale?: StarLiteLocale;
|
|
35
|
+
title?: string;
|
|
36
|
+
tabs: readonly {
|
|
37
|
+
id: string;
|
|
38
|
+
label: string;
|
|
39
|
+
items: readonly QuickSettingItem[];
|
|
40
|
+
}[];
|
|
41
|
+
}
|
|
42
|
+
/** Shared host controls only; no renderer, model, or application dependencies. */
|
|
43
|
+
export declare class QuickSettingsMenu {
|
|
44
|
+
#private;
|
|
45
|
+
readonly element: HTMLElement;
|
|
46
|
+
constructor(options: QuickSettingsOptions);
|
|
47
|
+
get isOpen(): boolean;
|
|
48
|
+
open(clientX: number, clientY: number): void;
|
|
49
|
+
close(): void;
|
|
50
|
+
update(options: QuickSettingsOptions): void;
|
|
51
|
+
destroy(): void;
|
|
52
|
+
}
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { resolveLocale, validateLocale } from "./locale.js";
|
|
2
|
+
const STYLE = `
|
|
3
|
+
[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}
|
|
4
|
+
[data-starlite-menu][hidden]{display:none!important}
|
|
5
|
+
[data-starlite-menu] *{box-sizing:border-box}
|
|
6
|
+
[data-starlite-menu] button,[data-starlite-menu] input{font:inherit;accent-color:#9faeff}
|
|
7
|
+
[data-starlite-menu] button{color:inherit;background:#ffffff09;border:1px solid #b4bfd528;border-radius:8px;padding:7px 9px}
|
|
8
|
+
[data-starlite-menu] button{cursor:pointer}
|
|
9
|
+
[data-starlite-menu] button:hover{background:#a7b3ff24}
|
|
10
|
+
[data-starlite-menu] :focus-visible{outline:2px solid #b3bfff;outline-offset:2px}
|
|
11
|
+
[data-starlite-menu] :disabled{opacity:.45;cursor:default}
|
|
12
|
+
[data-starlite-menu] .starlite-menu__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;font-weight:650;letter-spacing:.02em}
|
|
13
|
+
[data-starlite-menu] .starlite-menu__close{border:0;background:transparent;padding:2px 7px;font-size:20px;line-height:1.25}
|
|
14
|
+
[data-starlite-menu] [role=tablist]{display:flex;gap:4px;background:#0002;border-radius:9px;padding:3px;margin-bottom:8px}
|
|
15
|
+
[data-starlite-menu] [role=tab]{flex:1;border:0;background:transparent;padding:5px}
|
|
16
|
+
[data-starlite-menu] [role=tab][aria-selected=true]{background:#a7b3ff25;color:#dbe1ff}
|
|
17
|
+
[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}
|
|
18
|
+
[data-starlite-menu] .starlite-menu__row:last-child{border-bottom:0}
|
|
19
|
+
[data-starlite-menu] .starlite-menu__text{flex:1;min-width:0}
|
|
20
|
+
[data-starlite-menu] .starlite-menu__description{display:block;font-size:12px;color:#b1bbd2;margin-top:2px;font-weight:400}
|
|
21
|
+
[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}
|
|
22
|
+
[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}
|
|
23
|
+
[data-starlite-menu] .starlite-menu__toggle:checked{background:#899cf6}
|
|
24
|
+
[data-starlite-menu] .starlite-menu__toggle:checked:before{transform:translateX(14px)}
|
|
25
|
+
[data-starlite-menu] .starlite-menu__range-row{display:block}
|
|
26
|
+
[data-starlite-menu] .starlite-menu__range-label{display:flex;justify-content:space-between;gap:12px;margin-bottom:8px}
|
|
27
|
+
[data-starlite-menu] input[type=range]{display:block;width:100%;margin:0;cursor:pointer}
|
|
28
|
+
[data-starlite-menu] output{font-variant-numeric:tabular-nums;color:#c8d2ff}
|
|
29
|
+
[data-starlite-menu] .starlite-menu__select-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(90px,150px)}
|
|
30
|
+
[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}
|
|
31
|
+
[data-starlite-menu] .starlite-menu__select:after{content:"⌄";flex:none;color:#c8d2ff;font-size:15px;line-height:1}
|
|
32
|
+
[data-starlite-menu] .starlite-menu__select[aria-expanded=true]{background:#39445f;border-color:#a7b3ff}
|
|
33
|
+
[data-starlite-menu] .starlite-menu__select-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
34
|
+
[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}
|
|
35
|
+
[data-starlite-menu] .starlite-menu__options[hidden]{display:none!important}
|
|
36
|
+
[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}
|
|
37
|
+
[data-starlite-menu] .starlite-menu__option:after{content:"";width:12px;flex:none}
|
|
38
|
+
[data-starlite-menu] .starlite-menu__option[aria-selected=true]{background:#3a476a;color:#fff}
|
|
39
|
+
[data-starlite-menu] .starlite-menu__option[aria-selected=true]:after{content:"✓";color:#c8d2ff}
|
|
40
|
+
[data-starlite-menu] .starlite-menu__option:hover,[data-starlite-menu] .starlite-menu__option[data-active=true]{background:#4a587e;color:#fff}
|
|
41
|
+
[data-starlite-menu] .starlite-menu__action{display:block;width:100%;text-align:left;border:0;background:transparent;padding:8px 2px}
|
|
42
|
+
[data-starlite-menu] .starlite-menu__status{margin:8px 0 0;color:#ffc7c7;font-size:12px}
|
|
43
|
+
@media(prefers-reduced-motion:reduce){[data-starlite-menu] *{transition:none!important}}
|
|
44
|
+
`;
|
|
45
|
+
let instanceId = 0;
|
|
46
|
+
/** Shared host controls only; no renderer, model, or application dependencies. */
|
|
47
|
+
export class QuickSettingsMenu {
|
|
48
|
+
element = document.createElement("section");
|
|
49
|
+
#options;
|
|
50
|
+
#activeTab;
|
|
51
|
+
#structure = "";
|
|
52
|
+
#controls = new Map();
|
|
53
|
+
#selects = new Map();
|
|
54
|
+
#openSelect;
|
|
55
|
+
#draggingRanges = new Map();
|
|
56
|
+
#outputs = new Map();
|
|
57
|
+
#pending = new Map();
|
|
58
|
+
#generation = 0;
|
|
59
|
+
#destroyed = false;
|
|
60
|
+
#x = 0;
|
|
61
|
+
#y = 0;
|
|
62
|
+
#previousFocus;
|
|
63
|
+
#id = `starlite-menu-${++instanceId}`;
|
|
64
|
+
#outside = (event) => {
|
|
65
|
+
if (!this.isOpen)
|
|
66
|
+
return;
|
|
67
|
+
const path = event.composedPath();
|
|
68
|
+
if (!path.includes(this.element))
|
|
69
|
+
this.close();
|
|
70
|
+
else if (this.#openSelect) {
|
|
71
|
+
const select = this.#selects.get(this.#openSelect);
|
|
72
|
+
if (!path.includes(select.trigger) && !path.includes(select.listbox))
|
|
73
|
+
this.#closeSelect();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
#key = (event) => { if (this.isOpen && event.key === "Escape") {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
this.close();
|
|
79
|
+
} };
|
|
80
|
+
#rangeEnd = (event) => {
|
|
81
|
+
for (const [id, pointerId] of this.#draggingRanges) {
|
|
82
|
+
if (pointerId !== event.pointerId)
|
|
83
|
+
continue;
|
|
84
|
+
this.#draggingRanges.delete(id);
|
|
85
|
+
if (!this.#pending.has(id))
|
|
86
|
+
this.#sync();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
constructor(options) {
|
|
90
|
+
this.#validate(options);
|
|
91
|
+
this.#options = options;
|
|
92
|
+
this.#activeTab = options.tabs[0].id;
|
|
93
|
+
if (!document.querySelector("style[data-starlite-menu-style]")) {
|
|
94
|
+
const style = document.createElement("style");
|
|
95
|
+
style.dataset.starliteMenuStyle = "";
|
|
96
|
+
style.textContent = STYLE;
|
|
97
|
+
document.head.append(style);
|
|
98
|
+
}
|
|
99
|
+
this.element.dataset.starliteMenu = "";
|
|
100
|
+
this.element.dataset.starliteQuickSettings = "";
|
|
101
|
+
this.element.hidden = true;
|
|
102
|
+
this.element.setAttribute("role", "dialog");
|
|
103
|
+
this.element.setAttribute("aria-label", options.title ?? "StarLite");
|
|
104
|
+
this.element.addEventListener("contextmenu", event => event.preventDefault());
|
|
105
|
+
document.body.append(this.element);
|
|
106
|
+
document.addEventListener("pointerdown", this.#outside, true);
|
|
107
|
+
document.addEventListener("keydown", this.#key);
|
|
108
|
+
document.addEventListener("pointerup", this.#rangeEnd, true);
|
|
109
|
+
document.addEventListener("pointercancel", this.#rangeEnd, true);
|
|
110
|
+
this.#render();
|
|
111
|
+
}
|
|
112
|
+
get isOpen() { return !this.#destroyed && !this.element.hidden; }
|
|
113
|
+
open(clientX, clientY) {
|
|
114
|
+
if (this.#destroyed || !Number.isFinite(clientX) || !Number.isFinite(clientY))
|
|
115
|
+
return;
|
|
116
|
+
if (!this.isOpen)
|
|
117
|
+
this.#previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
|
|
118
|
+
this.#closeSelect();
|
|
119
|
+
this.#x = clientX;
|
|
120
|
+
this.#y = clientY;
|
|
121
|
+
this.element.hidden = false;
|
|
122
|
+
this.#position();
|
|
123
|
+
this.element.querySelector("[role=tab][aria-selected=true]")?.focus({ preventScroll: true });
|
|
124
|
+
this.element.dispatchEvent(new Event("starlite-menu-change"));
|
|
125
|
+
}
|
|
126
|
+
close() {
|
|
127
|
+
if (!this.isOpen)
|
|
128
|
+
return;
|
|
129
|
+
const containedFocus = this.element.contains(document.activeElement);
|
|
130
|
+
this.#closeSelect();
|
|
131
|
+
this.#draggingRanges.clear();
|
|
132
|
+
this.element.hidden = true;
|
|
133
|
+
if (containedFocus && this.#previousFocus?.isConnected)
|
|
134
|
+
this.#previousFocus.focus({ preventScroll: true });
|
|
135
|
+
this.element.dispatchEvent(new Event("starlite-menu-change"));
|
|
136
|
+
}
|
|
137
|
+
update(options) {
|
|
138
|
+
if (this.#destroyed)
|
|
139
|
+
return;
|
|
140
|
+
this.#validate(options);
|
|
141
|
+
this.#options = options;
|
|
142
|
+
if (!options.tabs.some(tab => tab.id === this.#activeTab))
|
|
143
|
+
this.#activeTab = options.tabs[0].id;
|
|
144
|
+
this.#render();
|
|
145
|
+
}
|
|
146
|
+
destroy() {
|
|
147
|
+
if (this.#destroyed)
|
|
148
|
+
return;
|
|
149
|
+
this.close();
|
|
150
|
+
this.#destroyed = true;
|
|
151
|
+
this.#generation++;
|
|
152
|
+
document.removeEventListener("pointerdown", this.#outside, true);
|
|
153
|
+
document.removeEventListener("keydown", this.#key);
|
|
154
|
+
document.removeEventListener("pointerup", this.#rangeEnd, true);
|
|
155
|
+
document.removeEventListener("pointercancel", this.#rangeEnd, true);
|
|
156
|
+
this.element.remove();
|
|
157
|
+
this.#controls.clear();
|
|
158
|
+
this.#outputs.clear();
|
|
159
|
+
this.#selects.clear();
|
|
160
|
+
}
|
|
161
|
+
#validate(options) {
|
|
162
|
+
validateLocale(options.locale);
|
|
163
|
+
const ids = new Set();
|
|
164
|
+
if (!options.tabs.length)
|
|
165
|
+
throw new Error("quick settings requires a tab");
|
|
166
|
+
for (const tab of options.tabs) {
|
|
167
|
+
if (!tab.id || ids.has(`tab:${tab.id}`))
|
|
168
|
+
throw new Error("quick settings tab IDs must be unique");
|
|
169
|
+
ids.add(`tab:${tab.id}`);
|
|
170
|
+
for (const item of tab.items) {
|
|
171
|
+
if (!item.id || ids.has(item.id))
|
|
172
|
+
throw new Error("quick settings item IDs must be unique");
|
|
173
|
+
ids.add(item.id);
|
|
174
|
+
if (item.kind === "range" && (![item.value, item.min, item.max, item.step ?? 1].every(Number.isFinite) || item.max <= item.min || (item.step ?? 1) <= 0))
|
|
175
|
+
throw new Error("invalid quick settings range");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
#item(id) { return this.#options.tabs.flatMap(tab => tab.items).find(item => item.id === id); }
|
|
180
|
+
#render() {
|
|
181
|
+
const structure = JSON.stringify([this.#activeTab, this.#options.title, this.#options.locale, 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])])]);
|
|
182
|
+
if (structure === this.#structure) {
|
|
183
|
+
this.#sync();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.#closeSelect();
|
|
187
|
+
this.#structure = structure;
|
|
188
|
+
this.#controls.clear();
|
|
189
|
+
this.#outputs.clear();
|
|
190
|
+
this.#selects.clear();
|
|
191
|
+
this.#draggingRanges.clear();
|
|
192
|
+
this.element.replaceChildren();
|
|
193
|
+
const header = document.createElement("div");
|
|
194
|
+
header.className = "starlite-menu__header";
|
|
195
|
+
const title = document.createElement("span");
|
|
196
|
+
title.textContent = this.#options.title ?? "StarLite";
|
|
197
|
+
const close = document.createElement("button");
|
|
198
|
+
close.type = "button";
|
|
199
|
+
close.className = "starlite-menu__close";
|
|
200
|
+
close.textContent = "×";
|
|
201
|
+
close.setAttribute("aria-label", resolveLocale(this.#options.locale) === "en" ? "Close menu" : "关闭菜单");
|
|
202
|
+
close.onclick = () => this.close();
|
|
203
|
+
header.append(title, close);
|
|
204
|
+
const tabs = document.createElement("div");
|
|
205
|
+
tabs.setAttribute("role", "tablist");
|
|
206
|
+
tabs.setAttribute("aria-label", resolveLocale(this.#options.locale) === "en" ? "Settings categories" : "设置分类");
|
|
207
|
+
for (const [index, tab] of this.#options.tabs.entries()) {
|
|
208
|
+
const button = document.createElement("button");
|
|
209
|
+
button.type = "button";
|
|
210
|
+
button.textContent = tab.label;
|
|
211
|
+
button.id = `${this.#id}-tab-${index}`;
|
|
212
|
+
button.setAttribute("role", "tab");
|
|
213
|
+
button.setAttribute("aria-selected", String(tab.id === this.#activeTab));
|
|
214
|
+
button.tabIndex = tab.id === this.#activeTab ? 0 : -1;
|
|
215
|
+
button.setAttribute("aria-controls", `${this.#id}-panel`);
|
|
216
|
+
button.onclick = () => { this.#activeTab = tab.id; this.#render(); this.element.querySelector("[role=tab][aria-selected=true]")?.focus({ preventScroll: true }); };
|
|
217
|
+
button.onkeydown = event => {
|
|
218
|
+
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
|
|
219
|
+
return;
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
const count = this.#options.tabs.length;
|
|
222
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? count - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + count) % count;
|
|
223
|
+
this.#activeTab = this.#options.tabs[next].id;
|
|
224
|
+
this.#render();
|
|
225
|
+
this.element.querySelector("[role=tab][aria-selected=true]")?.focus({ preventScroll: true });
|
|
226
|
+
};
|
|
227
|
+
tabs.append(button);
|
|
228
|
+
}
|
|
229
|
+
const panel = document.createElement("div");
|
|
230
|
+
panel.id = `${this.#id}-panel`;
|
|
231
|
+
panel.setAttribute("role", "tabpanel");
|
|
232
|
+
panel.setAttribute("aria-labelledby", `${this.#id}-tab-${this.#options.tabs.findIndex(tab => tab.id === this.#activeTab)}`);
|
|
233
|
+
for (const item of this.#options.tabs.find(tab => tab.id === this.#activeTab).items)
|
|
234
|
+
panel.append(this.#row(item));
|
|
235
|
+
const status = document.createElement("p");
|
|
236
|
+
status.className = "starlite-menu__status";
|
|
237
|
+
status.setAttribute("role", "status");
|
|
238
|
+
status.hidden = true;
|
|
239
|
+
this.element.append(header, tabs, panel, status);
|
|
240
|
+
this.element.setAttribute("aria-label", this.#options.title ?? "StarLite");
|
|
241
|
+
this.#sync();
|
|
242
|
+
}
|
|
243
|
+
#text(item) {
|
|
244
|
+
const text = document.createElement("span");
|
|
245
|
+
text.className = "starlite-menu__text";
|
|
246
|
+
text.textContent = item.label;
|
|
247
|
+
if (item.description) {
|
|
248
|
+
const description = document.createElement("span");
|
|
249
|
+
description.className = "starlite-menu__description";
|
|
250
|
+
description.textContent = item.description;
|
|
251
|
+
text.append(description);
|
|
252
|
+
}
|
|
253
|
+
return text;
|
|
254
|
+
}
|
|
255
|
+
#row(item) {
|
|
256
|
+
if (item.kind === "action") {
|
|
257
|
+
const row = document.createElement("div");
|
|
258
|
+
row.className = "starlite-menu__row";
|
|
259
|
+
const button = document.createElement("button");
|
|
260
|
+
button.type = "button";
|
|
261
|
+
button.className = "starlite-menu__action";
|
|
262
|
+
button.append(this.#text(item));
|
|
263
|
+
button.dataset.settingId = item.id;
|
|
264
|
+
button.onclick = () => { const current = this.#item(item.id); if (current?.kind === "action")
|
|
265
|
+
this.#invoke(current.id, current.onAction); };
|
|
266
|
+
this.#controls.set(item.id, button);
|
|
267
|
+
row.append(button);
|
|
268
|
+
return row;
|
|
269
|
+
}
|
|
270
|
+
const row = document.createElement(item.kind === "select" ? "div" : "label");
|
|
271
|
+
row.className = "starlite-menu__row";
|
|
272
|
+
const text = this.#text(item);
|
|
273
|
+
if (item.kind === "select") {
|
|
274
|
+
row.classList.add("starlite-menu__select-row");
|
|
275
|
+
const trigger = document.createElement("button");
|
|
276
|
+
trigger.type = "button";
|
|
277
|
+
trigger.dataset.settingId = item.id;
|
|
278
|
+
trigger.className = "starlite-menu__select";
|
|
279
|
+
const index = this.#selects.size;
|
|
280
|
+
text.id = `${this.#id}-select-label-${index}`;
|
|
281
|
+
trigger.setAttribute("role", "combobox");
|
|
282
|
+
trigger.setAttribute("aria-haspopup", "listbox");
|
|
283
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
284
|
+
trigger.setAttribute("aria-labelledby", text.id);
|
|
285
|
+
const label = document.createElement("span");
|
|
286
|
+
label.className = "starlite-menu__select-value";
|
|
287
|
+
trigger.append(label);
|
|
288
|
+
const listbox = document.createElement("div");
|
|
289
|
+
listbox.id = `${this.#id}-select-options-${index}`;
|
|
290
|
+
listbox.className = "starlite-menu__options";
|
|
291
|
+
listbox.hidden = true;
|
|
292
|
+
listbox.setAttribute("role", "listbox");
|
|
293
|
+
listbox.setAttribute("aria-labelledby", text.id);
|
|
294
|
+
trigger.setAttribute("aria-controls", listbox.id);
|
|
295
|
+
const options = item.options.map((option, optionIndex) => {
|
|
296
|
+
const node = document.createElement("button");
|
|
297
|
+
node.type = "button";
|
|
298
|
+
node.tabIndex = -1;
|
|
299
|
+
node.className = "starlite-menu__option";
|
|
300
|
+
node.dataset.value = option.value;
|
|
301
|
+
node.id = `${listbox.id}-${optionIndex}`;
|
|
302
|
+
node.textContent = option.label;
|
|
303
|
+
node.setAttribute("role", "option");
|
|
304
|
+
node.onpointerdown = event => event.preventDefault();
|
|
305
|
+
node.onclick = () => this.#chooseSelect(item.id, optionIndex);
|
|
306
|
+
listbox.append(node);
|
|
307
|
+
return node;
|
|
308
|
+
});
|
|
309
|
+
trigger.onclick = () => this.#openSelect === item.id ? this.#closeSelect() : this.#showSelect(item.id);
|
|
310
|
+
trigger.onkeydown = event => this.#selectKey(item.id, event);
|
|
311
|
+
trigger.onblur = () => { if (this.#openSelect === item.id)
|
|
312
|
+
this.#closeSelect(); };
|
|
313
|
+
this.#selects.set(item.id, { trigger, label, listbox, options, activeIndex: -1 });
|
|
314
|
+
this.#controls.set(item.id, trigger);
|
|
315
|
+
row.append(text, trigger, listbox);
|
|
316
|
+
return row;
|
|
317
|
+
}
|
|
318
|
+
const input = document.createElement("input");
|
|
319
|
+
input.dataset.settingId = item.id;
|
|
320
|
+
if (item.kind === "toggle") {
|
|
321
|
+
input.type = "checkbox";
|
|
322
|
+
input.className = "starlite-menu__toggle";
|
|
323
|
+
input.onchange = () => { const current = this.#item(item.id); if (current?.kind === "toggle")
|
|
324
|
+
this.#invoke(current.id, () => current.onChange(input.checked)); };
|
|
325
|
+
row.append(text, input);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
input.type = "range";
|
|
329
|
+
input.min = String(item.min);
|
|
330
|
+
input.max = String(item.max);
|
|
331
|
+
input.step = String(item.step ?? 1);
|
|
332
|
+
input.onpointerdown = event => { if (event.button === 0 && !input.disabled)
|
|
333
|
+
this.#draggingRanges.set(item.id, event.pointerId); };
|
|
334
|
+
row.classList.add("starlite-menu__range-row");
|
|
335
|
+
const label = document.createElement("span");
|
|
336
|
+
label.className = "starlite-menu__range-label";
|
|
337
|
+
const output = document.createElement("output");
|
|
338
|
+
this.#outputs.set(item.id, output);
|
|
339
|
+
label.append(text, output);
|
|
340
|
+
input.oninput = () => { const current = this.#item(item.id); if (current?.kind === "range") {
|
|
341
|
+
const value = Number(input.value);
|
|
342
|
+
output.textContent = current.formatValue?.(value) ?? String(value);
|
|
343
|
+
this.#invoke(current.id, () => current.onChange(value));
|
|
344
|
+
} };
|
|
345
|
+
row.append(label, input);
|
|
346
|
+
}
|
|
347
|
+
this.#controls.set(item.id, input);
|
|
348
|
+
return row;
|
|
349
|
+
}
|
|
350
|
+
#sync() {
|
|
351
|
+
for (const [id, control] of this.#controls) {
|
|
352
|
+
const item = this.#item(id);
|
|
353
|
+
if (!item)
|
|
354
|
+
continue;
|
|
355
|
+
// Sliders remain usable while host persistence is pending.
|
|
356
|
+
control.disabled = item.disabled === true || (item.kind !== "range" && this.#pending.has(id));
|
|
357
|
+
if (item.kind === "toggle")
|
|
358
|
+
control.checked = item.value;
|
|
359
|
+
else if (item.kind !== "action") {
|
|
360
|
+
// Persisted values may arrive behind an ongoing drag. Keep its thumb
|
|
361
|
+
// under the pointer; the next authoritative update will settle it.
|
|
362
|
+
if (item.kind === "range" && this.#draggingRanges.has(id))
|
|
363
|
+
continue;
|
|
364
|
+
control.value = String(item.value);
|
|
365
|
+
if (item.kind === "range")
|
|
366
|
+
this.#outputs.get(id).textContent = item.formatValue?.(item.value) ?? String(item.value);
|
|
367
|
+
else
|
|
368
|
+
this.#syncSelect(id, item.value);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
#syncSelect(id, value) {
|
|
373
|
+
const select = this.#selects.get(id);
|
|
374
|
+
if (!select)
|
|
375
|
+
return;
|
|
376
|
+
const option = select.options.find(node => node.dataset.value === value);
|
|
377
|
+
select.label.textContent = option?.textContent ?? "—";
|
|
378
|
+
for (const node of select.options)
|
|
379
|
+
node.setAttribute("aria-selected", String(node === option));
|
|
380
|
+
if (select.trigger.disabled && this.#openSelect === id)
|
|
381
|
+
this.#closeSelect();
|
|
382
|
+
}
|
|
383
|
+
#showSelect(id, index) {
|
|
384
|
+
const select = this.#selects.get(id);
|
|
385
|
+
if (!select || select.trigger.disabled || !select.options.length)
|
|
386
|
+
return;
|
|
387
|
+
this.#closeSelect();
|
|
388
|
+
this.#openSelect = id;
|
|
389
|
+
select.listbox.hidden = false;
|
|
390
|
+
select.trigger.setAttribute("aria-expanded", "true");
|
|
391
|
+
this.#activateOption(id, index ?? Math.max(0, select.options.findIndex(node => node.dataset.value === select.trigger.value)));
|
|
392
|
+
this.element.dispatchEvent(new Event("starlite-menu-change"));
|
|
393
|
+
}
|
|
394
|
+
#activateOption(id, index) {
|
|
395
|
+
const select = this.#selects.get(id);
|
|
396
|
+
if (!select)
|
|
397
|
+
return;
|
|
398
|
+
select.activeIndex = Math.max(0, Math.min(index, select.options.length - 1));
|
|
399
|
+
for (const [optionIndex, node] of select.options.entries())
|
|
400
|
+
node.dataset.active = String(optionIndex === select.activeIndex);
|
|
401
|
+
const active = select.options[select.activeIndex];
|
|
402
|
+
if (active) {
|
|
403
|
+
select.trigger.setAttribute("aria-activedescendant", active.id);
|
|
404
|
+
active.scrollIntoView({ block: "nearest" });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
#closeSelect() {
|
|
408
|
+
if (!this.#openSelect)
|
|
409
|
+
return;
|
|
410
|
+
const select = this.#selects.get(this.#openSelect);
|
|
411
|
+
this.#openSelect = undefined;
|
|
412
|
+
if (select) {
|
|
413
|
+
select.listbox.hidden = true;
|
|
414
|
+
select.trigger.setAttribute("aria-expanded", "false");
|
|
415
|
+
select.trigger.removeAttribute("aria-activedescendant");
|
|
416
|
+
for (const node of select.options)
|
|
417
|
+
delete node.dataset.active;
|
|
418
|
+
}
|
|
419
|
+
this.element.dispatchEvent(new Event("starlite-menu-change"));
|
|
420
|
+
}
|
|
421
|
+
#chooseSelect(id, index) {
|
|
422
|
+
const select = this.#selects.get(id), current = this.#item(id), option = select?.options[index];
|
|
423
|
+
if (!select || !option || select.trigger.disabled || current?.kind !== "select")
|
|
424
|
+
return;
|
|
425
|
+
const value = option.dataset.value;
|
|
426
|
+
select.trigger.value = value;
|
|
427
|
+
this.#syncSelect(id, value);
|
|
428
|
+
this.#closeSelect();
|
|
429
|
+
select.trigger.focus({ preventScroll: true });
|
|
430
|
+
this.#invoke(id, () => current.onChange(value));
|
|
431
|
+
}
|
|
432
|
+
#selectKey(id, event) {
|
|
433
|
+
const select = this.#selects.get(id);
|
|
434
|
+
if (!select)
|
|
435
|
+
return;
|
|
436
|
+
if (event.key === "Escape" && this.#openSelect === id) {
|
|
437
|
+
event.preventDefault();
|
|
438
|
+
event.stopPropagation();
|
|
439
|
+
this.#closeSelect();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (event.key === "Tab") {
|
|
443
|
+
this.#closeSelect();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End", "Enter", " "].includes(event.key))
|
|
447
|
+
return;
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
if (event.key === "Home" || event.key === "End") {
|
|
450
|
+
const index = event.key === "Home" ? 0 : select.options.length - 1;
|
|
451
|
+
if (this.#openSelect !== id)
|
|
452
|
+
this.#showSelect(id, index);
|
|
453
|
+
else
|
|
454
|
+
this.#activateOption(id, index);
|
|
455
|
+
}
|
|
456
|
+
else if (this.#openSelect !== id)
|
|
457
|
+
this.#showSelect(id);
|
|
458
|
+
else if (event.key === "Enter" || event.key === " ")
|
|
459
|
+
this.#chooseSelect(id, select.activeIndex);
|
|
460
|
+
else
|
|
461
|
+
this.#activateOption(id, (select.activeIndex + (event.key === "ArrowDown" ? 1 : -1) + select.options.length) % select.options.length);
|
|
462
|
+
}
|
|
463
|
+
#invoke(id, callback) {
|
|
464
|
+
const token = ++this.#generation;
|
|
465
|
+
this.#pending.set(id, token);
|
|
466
|
+
const status = this.element.querySelector("[role=status]");
|
|
467
|
+
status.hidden = true;
|
|
468
|
+
try {
|
|
469
|
+
const result = callback();
|
|
470
|
+
if (result && typeof result.then === "function") {
|
|
471
|
+
// Do not reset a just-edited input before the host returns its new state.
|
|
472
|
+
result.catch(() => { this.#reportError(); this.#sync(); }).finally(() => this.#finish(id, token));
|
|
473
|
+
}
|
|
474
|
+
else
|
|
475
|
+
this.#finish(id, token);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
this.#pending.delete(id);
|
|
479
|
+
this.#reportError();
|
|
480
|
+
this.#sync();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
#finish(id, token) {
|
|
484
|
+
if (this.#pending.get(id) !== token)
|
|
485
|
+
return;
|
|
486
|
+
this.#pending.delete(id);
|
|
487
|
+
const control = this.#controls.get(id);
|
|
488
|
+
if (control)
|
|
489
|
+
control.disabled = this.#item(id)?.disabled === true;
|
|
490
|
+
}
|
|
491
|
+
#reportError() {
|
|
492
|
+
if (this.#destroyed)
|
|
493
|
+
return;
|
|
494
|
+
const status = this.element.querySelector("[role=status]");
|
|
495
|
+
status.textContent = resolveLocale(this.#options.locale) === "en" ? "Could not apply this setting. Please try again." : "未能应用设置,请重试。";
|
|
496
|
+
status.hidden = false;
|
|
497
|
+
}
|
|
498
|
+
#position() {
|
|
499
|
+
// Position once per explicit open. Host/model resizes and expanded controls
|
|
500
|
+
// must never move a slider out from under an active pointer.
|
|
501
|
+
this.element.style.maxWidth = "";
|
|
502
|
+
this.element.style.maxHeight = "";
|
|
503
|
+
const rect = this.element.getBoundingClientRect();
|
|
504
|
+
const width = rect.width || Math.min(300, innerWidth - 16), height = rect.height || this.element.offsetHeight;
|
|
505
|
+
this.element.style.left = `${Math.max(8, Math.min(this.#x, innerWidth - width - 8))}px`;
|
|
506
|
+
const top = Math.max(8, Math.min(this.#y, innerHeight - height - 8));
|
|
507
|
+
this.element.style.top = `${top}px`;
|
|
508
|
+
this.element.style.maxHeight = `calc(100vh - ${top + 8}px)`;
|
|
509
|
+
}
|
|
510
|
+
}
|