dsh-plugin-vrmpet3d 0.0.0-stage → 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/LICENSE +43 -0
- package/README.md +249 -2
- package/assets/models/BlueFatFish.vrm.br +0 -0
- package/assets/models/README.md +99 -0
- package/assets/models/SampleModel.vrm.br +0 -0
- package/assets/models/vrmpet3d-models.json +116 -0
- package/assets/motions/README.md +177 -0
- package/assets/motions/attention/wave hand 1.vrma.br +0 -0
- package/assets/motions/attention/wave hand 2.vrma.br +0 -0
- package/assets/motions/attention/wave hand 3.vrma.br +0 -0
- package/assets/motions/dance/dance1.vrma.br +0 -0
- package/assets/motions/dance/dance2.vrma.br +0 -0
- package/assets/motions/fail/fall down1.vrma.br +0 -0
- package/assets/motions/fail/fall down2.vrma.br +0 -0
- package/assets/motions/idle/hungry1.vrma.br +0 -0
- package/assets/motions/idle/hungry2.vrma.br +0 -0
- package/assets/motions/idle/idle lookaround1.vrma.br +0 -0
- package/assets/motions/idle/idle lookaround2.vrma.br +0 -0
- package/assets/motions/idle/idle stretch1.vrma.br +0 -0
- package/assets/motions/idle/idle stretch2.vrma.br +0 -0
- package/assets/motions/idle/idle stretch3.vrma.br +0 -0
- package/assets/motions/idle/idle stretch4.vrma.br +0 -0
- package/assets/motions/idle/idle.vrma.br +0 -0
- package/assets/motions/idle/idle2.vrma.br +0 -0
- package/assets/motions/idle/idle3.vrma.br +0 -0
- package/assets/motions/idle/idle4.vrma.br +0 -0
- package/assets/motions/idle/touch hair1.vrma.br +0 -0
- package/assets/motions/idle/touch hair2.vrma.br +0 -0
- package/assets/motions/offline/sleeping.vrma.br +0 -0
- package/assets/motions/success/jump 1.vrma.br +0 -0
- package/assets/motions/success/jump 2.vrma.br +0 -0
- package/assets/motions/success/jump 3.vrma.br +0 -0
- package/assets/motions/vrmpet3d-motions.json +283 -0
- package/assets/motions/working/dance1.vrma.br +0 -0
- package/cordis.patch.yml +7 -0
- package/desktop/main.js +1782 -0
- package/desktop/package.json +7 -0
- package/desktop/preload.js +66 -0
- package/desktop/probe-motion-picker.js +122 -0
- package/desktop/probe-parts-editor.js +247 -0
- package/lib/brotli.js +74 -0
- package/lib/client.core.js +7563 -0
- package/lib/client.js +5031 -0
- package/lib/client.menu.js +501 -0
- package/lib/client.scene.js +5128 -0
- package/lib/client.vendor.js +55286 -0
- package/lib/config-page.html +194 -0
- package/lib/config-page.js +4754 -0
- package/lib/core.js +7541 -0
- package/lib/desktop-page.html +131 -0
- package/lib/desktop-page.js +3927 -0
- package/lib/desktop.js +536 -0
- package/lib/env.js +20 -0
- package/lib/global-page.html +338 -0
- package/lib/index.js +4170 -0
- package/lib/menu.js +479 -0
- package/lib/model-picker.js +794 -0
- package/lib/pages.css +350 -0
- package/lib/picker-page.html +64 -0
- package/lib/picker-page.js +145 -0
- package/lib/placeholder.js +566 -0
- package/lib/scene.js +5106 -0
- package/lib/settings.js +259 -0
- package/lib/setup-page.html +240 -0
- package/lib/setup-page.js +706 -0
- package/lib/vrm-info.js +926 -0
- package/package.json +55 -4
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// dsh-plugin-vrmpet3d — generated by scripts/client-chunks.mjs; do not edit.
|
|
2
|
+
//
|
|
3
|
+
// This is lib/menu.js verbatim, plus the DSH client module
|
|
4
|
+
// registration that makes it deliverable: only client.<name>.js files are served
|
|
5
|
+
// to the browser, so the source cannot be loaded under its own name.
|
|
6
|
+
//
|
|
7
|
+
// Edit lib/menu.js, not this file. Then run:
|
|
8
|
+
// node scripts/client-chunks.mjs
|
|
9
|
+
/**
|
|
10
|
+
* THE right-click menu, for both pets.
|
|
11
|
+
*
|
|
12
|
+
* It replaces the two settings panels. The app's pet and the floating window each grew a panel — same
|
|
13
|
+
* product, two control sets, so "where do I turn physics off" depended on which pet you were looking at —
|
|
14
|
+
* and the panels were then unified into one shared file, which was the wrong unification: a pet is not a
|
|
15
|
+
* settings dialog, and every setting in those panels already has a home in the two settings pages
|
|
16
|
+
* (模型参数 and 通用设置). What a pet owes a right-click is a SHORT list of things that are about the pet:
|
|
17
|
+
* put its view and its size back, open one of the two settings pages, hand it to the other surface, close
|
|
18
|
+
* it, and restart or quit the app it lives in.
|
|
19
|
+
*
|
|
20
|
+
* So this file is that list, in ONE place, because the two surfaces' menus differ by exactly one row:
|
|
21
|
+
* the in-app pet offers 切换回窗口外 and the window offers 切换回窗口内. Everything else — the eight entries,
|
|
22
|
+
* their order, their words in both languages, what "disabled" means — is defined here, and a surface only
|
|
23
|
+
* says which way its switch goes and what each id DOES.
|
|
24
|
+
*
|
|
25
|
+
* The module owns the MARKUP and the BEHAVIOUR (open, hover, keyboard, close on an outside press or
|
|
26
|
+
* Escape); the surface owns WHERE it goes. That split is deliberate and it is what the old panel got
|
|
27
|
+
* wrong: the panel owned its geometry (a fixed 240 px box anchored beside the pet), which the floating
|
|
28
|
+
* window then had to fight — the window IS the pet's box, so a menu placed outside it was clipped unless
|
|
29
|
+
* the page both measured it and grew the window. A menu with no geometry of its own can be placed in
|
|
30
|
+
* frame coordinates by the page that knows the frame.
|
|
31
|
+
*/
|
|
32
|
+
(function () {
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The entries, in the order they are shown.
|
|
37
|
+
*
|
|
38
|
+
* `id` is what a surface switches on and what the tests assert, so the ORDER lives here and nowhere
|
|
39
|
+
* else: a menu whose entries drifted apart between the two pets would be the same debt as two panels.
|
|
40
|
+
*
|
|
41
|
+
* 重启 DeepSeek Harness and 退出 DeepSeek Harness were the last two entries until 2026-10-12, when the
|
|
42
|
+
* operator had them removed ("内置宠物和外置宠物都去掉关闭和重启dsh的功能和项目"). Nothing else in the product
|
|
43
|
+
* called `/vrmpet3d/harness`, so the route went with them.
|
|
44
|
+
*
|
|
45
|
+
* `surface` is the one entry a surface may NOT SHOW (see the `showSurface` option): the floating window's own
|
|
46
|
+
* 关闭独立宠物 does exactly what its 切换回窗口内 did, and the operator removed the duplicate on 2026-10-12
|
|
47
|
+
* (「外置桌宠的'切换回窗口内'选项现在和'关闭独立宠物'功能重复了,移除'切换回窗口内'选项」). The entry stays in this
|
|
48
|
+
* list because the APP's pet still needs it — 切换回窗口外 is the only way out of DSH — so the order and the id
|
|
49
|
+
* are still one definition; only the row's PRESENCE differs per surface.
|
|
50
|
+
*/
|
|
51
|
+
var ITEMS = [
|
|
52
|
+
'resetView',
|
|
53
|
+
'resetSize',
|
|
54
|
+
'globalSettings',
|
|
55
|
+
/**
|
|
56
|
+
* 切换模型 — the model picker's own page (`/vrmpet3d/picker`), added 2026-10-13.
|
|
57
|
+
*
|
|
58
|
+
* The operator asked for it in BOTH pets' menus with the content of the guide's 偏好模型 step
|
|
59
|
+
* (「在内置和外置桌宠右键菜单增加一个'切换模型'面板,面板中和初次安装向导模型页完全一样」). It sits beside
|
|
60
|
+
* 当前模型参数设置 rather than inside it: that page configures the model ON SCREEN, while this one decides
|
|
61
|
+
* WHICH model that is — and picking one there changes what every other page is about.
|
|
62
|
+
*/
|
|
63
|
+
'switchModel',
|
|
64
|
+
/**
|
|
65
|
+
* 跳支舞 — a random clip out of the dance folder, played once (operator, 2026-10-14).
|
|
66
|
+
*
|
|
67
|
+
* Beside the model rows rather than inside a page: it is something to DO to the pet in front of you, and it is the one row
|
|
68
|
+
* whose effect ends by itself (the clip finishes, or the user double-clicks the pet, or work starts).
|
|
69
|
+
*/
|
|
70
|
+
'dance',
|
|
71
|
+
/**
|
|
72
|
+
* 重载模型 — refresh the model on screen without touching anything else (operator, 2026-10-14: 「给内置和外置宠物的右键菜单
|
|
73
|
+
* 新增“重载模型”的选项,用来刷新模型」).
|
|
74
|
+
*
|
|
75
|
+
* It sits beside 切换模型 rather than inside a settings page because it is a gesture about the pet in front of you: a
|
|
76
|
+
* model whose file was re-exported on disk, or a scene that has drifted (a bad clip, a chain left stretched), is fixed
|
|
77
|
+
* by re-reading it. The surfaces do the work — this file only names the row.
|
|
78
|
+
*/
|
|
79
|
+
'reloadModel',
|
|
80
|
+
'modelSettings',
|
|
81
|
+
'surface',
|
|
82
|
+
'gravity',
|
|
83
|
+
'closePet',
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The rows that are SWITCHES rather than actions, and therefore carry a check mark and `aria-checked`.
|
|
88
|
+
*
|
|
89
|
+
* 重力 is the only one: the entry both reports the state and flips it, and a switch that cannot show which
|
|
90
|
+
* way it is thrown is the bug this list exists to prevent.
|
|
91
|
+
*/
|
|
92
|
+
var TOGGLES = ['gravity'];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The words, in both languages.
|
|
96
|
+
*
|
|
97
|
+
* Here rather than in each surface's own `STRINGS`: the menu is the same object on both surfaces, and a
|
|
98
|
+
* label that had to be kept in two files is exactly how the two panels drifted. A surface picks the
|
|
99
|
+
* language and the rows that differ (`surfaceWindow`/`surfaceApp` and `closePetWindow`/`closePetApp`).
|
|
100
|
+
*
|
|
101
|
+
* `surfaceApp` (切换回窗口内 / Move back inside the app) currently has NO caller: the window is the only
|
|
102
|
+
* surface that would hand the pet back, and its switch row is gone. It is kept deliberately — the row is
|
|
103
|
+
* defined per DIRECTION, and the next reader who asks "can this hand the pet back at all?" gets the answer
|
|
104
|
+
* from the table rather than from the absence of one. The alternative (deleting the direction) would make
|
|
105
|
+
* `toSurface` a constant with one possible value, which reads as a bug rather than as a decision.
|
|
106
|
+
*/
|
|
107
|
+
var LABELS = {
|
|
108
|
+
en: {
|
|
109
|
+
resetView: 'Reset view',
|
|
110
|
+
resetSize: 'Reset size',
|
|
111
|
+
globalSettings: 'General settings',
|
|
112
|
+
switchModel: 'Switch model',
|
|
113
|
+
reloadModel: 'Reload model',
|
|
114
|
+
dance: 'Dance for me',
|
|
115
|
+
modelSettings: "This model's parameters",
|
|
116
|
+
surfaceWindow: 'Move to the floating window',
|
|
117
|
+
surfaceApp: 'Move back inside the app',
|
|
118
|
+
surfaceOff: 'disabled while debug mode is on',
|
|
119
|
+
gravity: 'Gravity',
|
|
120
|
+
/**
|
|
121
|
+
* Why the 重力 row is off, as its tooltip — the same courtesy the disabled 切换 row gets. A switch with
|
|
122
|
+
* no explanation reads as a pet that has stopped working.
|
|
123
|
+
*/
|
|
124
|
+
gravityOff: 'Off: no throwing and no falling — put the pet down anywhere',
|
|
125
|
+
/**
|
|
126
|
+
* 关闭宠物, in the two meanings it has on the two surfaces (operator, 2026-10-12).
|
|
127
|
+
*
|
|
128
|
+
* The app's pet is HIDDEN — it stays gone until 显示宠物 is pressed — while the floating window's pet is
|
|
129
|
+
* handed BACK inside the app, which is the only thing "closing" that window can mean: the window is the
|
|
130
|
+
* pet's box, so a window with no pet in it is nothing.
|
|
131
|
+
*/
|
|
132
|
+
closePetApp: 'Hide pet',
|
|
133
|
+
closePetWindow: 'Close the floating pet',
|
|
134
|
+
title: 'Pet menu',
|
|
135
|
+
},
|
|
136
|
+
zh: {
|
|
137
|
+
resetView: '重置视角',
|
|
138
|
+
resetSize: '重置大小',
|
|
139
|
+
globalSettings: '通用设置',
|
|
140
|
+
switchModel: '切换模型',
|
|
141
|
+
reloadModel: '重载模型',
|
|
142
|
+
dance: '跳支舞',
|
|
143
|
+
modelSettings: '当前模型参数设置',
|
|
144
|
+
surfaceWindow: '切换回窗口外',
|
|
145
|
+
surfaceApp: '切换回窗口内',
|
|
146
|
+
surfaceOff: '调试模式下不生效',
|
|
147
|
+
gravity: '重力',
|
|
148
|
+
gravityOff: '已关闭:不抛掷、不下坠,可以随意摆放',
|
|
149
|
+
closePetApp: '隐藏宠物',
|
|
150
|
+
closePetWindow: '关闭独立宠物',
|
|
151
|
+
title: '桌宠菜单',
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** The words for one language, defaulting to Chinese — the operator's own language. */
|
|
156
|
+
function strings(language) {
|
|
157
|
+
return LABELS[language === 'en' ? 'en' : 'zh'];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The menu's own styles.
|
|
162
|
+
*
|
|
163
|
+
* Self-contained and namespaced under `.vrmpet3d-menu`, like the panel's were, so a surface can use it
|
|
164
|
+
* without adopting the other surface's stylesheet. `position:fixed` is not decoration: both surfaces
|
|
165
|
+
* place the menu in VIEWPORT coordinates — the app overlay in DSH's frame, the window in its own box —
|
|
166
|
+
* and an absolutely positioned menu would drift with a scrolled document.
|
|
167
|
+
*/
|
|
168
|
+
var STYLE = [
|
|
169
|
+
'.vrmpet3d-menu{position:fixed;z-index:40;box-sizing:border-box;min-width:168px;max-width:320px;padding:5px;',
|
|
170
|
+
' border-radius:9px;font:13px/1.5 "Segoe UI",system-ui,-apple-system,"Microsoft YaHei",sans-serif;',
|
|
171
|
+
' background:light-dark(#f9fafb,#353638);color:light-dark(#111827,#e5e7eb);',
|
|
172
|
+
' border:1px solid light-dark(#0000001a,#ffffff1f);box-shadow:0 10px 30px #0003;',
|
|
173
|
+
' pointer-events:auto;user-select:none;-webkit-user-select:none;cursor:default}',
|
|
174
|
+
'.vrmpet3d-menu[hidden]{display:none}',
|
|
175
|
+
'.vrmpet3d-menu-item{display:block;width:100%;box-sizing:border-box;text-align:left;font:inherit;cursor:pointer;',
|
|
176
|
+
' color:inherit;background:none;border:0;border-radius:6px;padding:5px 9px;white-space:nowrap}',
|
|
177
|
+
'.vrmpet3d-menu-item:hover:not([disabled]),.vrmpet3d-menu-item:focus-visible:not([disabled]){',
|
|
178
|
+
' background:light-dark(#00000014,#ffffff1f);outline:none}',
|
|
179
|
+
'.vrmpet3d-menu-item[disabled]{opacity:.45;cursor:default}',
|
|
180
|
+
/**
|
|
181
|
+
* A SWITCH row's mark, as a `::before` pseudo-element rather than a child node.
|
|
182
|
+
*
|
|
183
|
+
* The label is the button's `textContent` — the probe reads it, and so does a screen reader — so a mark
|
|
184
|
+
* added as a child would become part of the entry's own name. The slot is reserved in BOTH states so the
|
|
185
|
+
* labels stay aligned whether the switch is on or off.
|
|
186
|
+
*/
|
|
187
|
+
'.vrmpet3d-menu-item[data-toggle]::before{content:"";display:inline-block;width:1.15em}',
|
|
188
|
+
'.vrmpet3d-menu-item[data-toggle][data-checked="true"]::before{content:"\\2713"}',
|
|
189
|
+
].join('\n');
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Put the menu's stylesheet into a document, once.
|
|
193
|
+
*
|
|
194
|
+
* The module has no document of its own (a surface mounts pages the suite creates), so whoever owns the
|
|
195
|
+
* document asks for it. Idempotent by id, because both pets can be on one page.
|
|
196
|
+
*/
|
|
197
|
+
function injectStyle(doc, id) {
|
|
198
|
+
if (!doc || !doc.head || typeof doc.createElement !== 'function') return null;
|
|
199
|
+
var key = id || 'vrmpet3d-menu-style';
|
|
200
|
+
if (typeof doc.getElementById === 'function' && doc.getElementById(key)) return doc.getElementById(key);
|
|
201
|
+
var style = doc.createElement('style');
|
|
202
|
+
style.id = key;
|
|
203
|
+
style.textContent = STYLE;
|
|
204
|
+
doc.head.appendChild(style);
|
|
205
|
+
return style;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function clamp(value, min, max) {
|
|
209
|
+
return Math.min(max, Math.max(min, value));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Build the menu.
|
|
214
|
+
*
|
|
215
|
+
* @param {object} options
|
|
216
|
+
* document the document to build in and to listen on
|
|
217
|
+
* container where to append it; defaults to `document.body`
|
|
218
|
+
* language 'zh' or 'en' (anything else is Chinese)
|
|
219
|
+
* toSurface 'window' or 'app' — which way the 切换 row goes and which words 关闭宠物 uses; the two
|
|
220
|
+
* rows that differ between the two pets
|
|
221
|
+
* showSurface false omits the 切换 row entirely. Only the floating window passes it, because its own
|
|
222
|
+
* 关闭独立宠物 IS that action (operator, 2026-10-12: they were duplicates). The app's pet
|
|
223
|
+
* has nowhere else to go, so its row stays.
|
|
224
|
+
* surfaceAllowed false disables that row (the DEBUG mode: both pets are already on screen, so
|
|
225
|
+
* "switch to the other surface" has no meaning — the operator's 非debug模式生效)
|
|
226
|
+
* onChoose called with the item's id, after the menu closes
|
|
227
|
+
* onOpen/onClose notified around the open state, so a surface can re-measure or report its rect
|
|
228
|
+
* @returns {{element: Element, open: Function, place: Function, close: Function, isOpen: Function,
|
|
229
|
+
* size: Function, setSurface: Function, setSurfaceAllowed: Function, setChecked: Function,
|
|
230
|
+
* items: Function, destroy: Function, strings: object}}
|
|
231
|
+
*/
|
|
232
|
+
function createMenu(options) {
|
|
233
|
+
var doc = options.document;
|
|
234
|
+
var text = strings(options.language);
|
|
235
|
+
var container = options.container || doc.body;
|
|
236
|
+
var onChoose = typeof options.onChoose === 'function' ? options.onChoose : function () {};
|
|
237
|
+
var toSurface = options.toSurface === 'app' ? 'app' : 'window';
|
|
238
|
+
var surfaceAllowed = options.surfaceAllowed !== false;
|
|
239
|
+
/** Whether this surface offers the 切换 row at all. See the `showSurface` option. */
|
|
240
|
+
var showSurface = options.showSurface !== false;
|
|
241
|
+
|
|
242
|
+
var element = doc.createElement('div');
|
|
243
|
+
element.className = 'vrmpet3d-menu';
|
|
244
|
+
element.setAttribute('role', 'menu');
|
|
245
|
+
element.setAttribute('aria-label', text.title);
|
|
246
|
+
element.hidden = true;
|
|
247
|
+
container.appendChild(element);
|
|
248
|
+
|
|
249
|
+
/** The item nodes currently built, in order. */
|
|
250
|
+
var nodes = [];
|
|
251
|
+
/** The label each node carries, so a surface can be asked what it is showing. */
|
|
252
|
+
var built = [];
|
|
253
|
+
/**
|
|
254
|
+
* Which way the SWITCH rows are thrown, per id, as the surface last said.
|
|
255
|
+
*
|
|
256
|
+
* A surface sets this at OPEN time (`setChecked`), like `setSurfaceAllowed`: the menu is rebuilt on every
|
|
257
|
+
* open, and the state it must show is the surface's own preference rather than anything this module could
|
|
258
|
+
* remember. `false` is the default, so a switch a surface never mentions renders as off rather than as an
|
|
259
|
+
* unmarked button nobody can read.
|
|
260
|
+
*/
|
|
261
|
+
var checkedState = {};
|
|
262
|
+
|
|
263
|
+
/** The label for one id, from the state this menu was built with. */
|
|
264
|
+
function labelFor(id) {
|
|
265
|
+
if (id === 'surface') return toSurface === 'app' ? text.surfaceApp : text.surfaceWindow;
|
|
266
|
+
if (id === 'closePet') return toSurface === 'app' ? text.closePetWindow : text.closePetApp;
|
|
267
|
+
return text[id] || id;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Rebuild the items. Cheap, and it is what keeps the disabled and checked rows honest on every open. */
|
|
271
|
+
function render() {
|
|
272
|
+
while (element.firstChild) element.removeChild(element.firstChild);
|
|
273
|
+
nodes = [];
|
|
274
|
+
built = [];
|
|
275
|
+
ITEMS.forEach(function (id) {
|
|
276
|
+
// The 切换 row is the one entry a surface may omit — the window's 关闭独立宠物 already does its job.
|
|
277
|
+
if (id === 'surface' && !showSurface) return;
|
|
278
|
+
var disabled = id === 'surface' && surfaceAllowed !== true;
|
|
279
|
+
var toggle = TOGGLES.indexOf(id) !== -1;
|
|
280
|
+
var checked = toggle && checkedState[id] === true;
|
|
281
|
+
var item = doc.createElement('button');
|
|
282
|
+
item.type = 'button';
|
|
283
|
+
item.className = 'vrmpet3d-menu-item';
|
|
284
|
+
// A switch says so in its ROLE: `menuitemcheckbox` is what makes a screen reader announce the state
|
|
285
|
+
// rather than read `aria-checked` on a row that claims to be an ordinary action.
|
|
286
|
+
item.setAttribute('role', toggle ? 'menuitemcheckbox' : 'menuitem');
|
|
287
|
+
item.setAttribute('data-item', id);
|
|
288
|
+
var label = labelFor(id);
|
|
289
|
+
item.textContent = label;
|
|
290
|
+
if (toggle) {
|
|
291
|
+
item.setAttribute('data-toggle', 'true');
|
|
292
|
+
item.setAttribute('data-checked', checked ? 'true' : 'false');
|
|
293
|
+
item.setAttribute('aria-checked', checked ? 'true' : 'false');
|
|
294
|
+
}
|
|
295
|
+
if (disabled) {
|
|
296
|
+
item.disabled = true;
|
|
297
|
+
item.setAttribute('aria-disabled', 'true');
|
|
298
|
+
/**
|
|
299
|
+
* Why the row is greyed, in words: a disabled control with no explanation is a bug report
|
|
300
|
+
* waiting to happen ("the switch does nothing"). The reason is the operator's own qualifier —
|
|
301
|
+
* 非debug模式生效 — spelled out for the mode that turns it off.
|
|
302
|
+
*/
|
|
303
|
+
item.title = text.surfaceOff;
|
|
304
|
+
} else {
|
|
305
|
+
if (toggle && !checked) item.title = text.gravityOff;
|
|
306
|
+
item.addEventListener('click', function (event) {
|
|
307
|
+
if (event && event.preventDefault) event.preventDefault();
|
|
308
|
+
choose(id);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
element.appendChild(item);
|
|
312
|
+
nodes.push(item);
|
|
313
|
+
built.push({ id: id, label: label, disabled: disabled, checked: checked });
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The document-level guards, live only while the menu is open.
|
|
319
|
+
*
|
|
320
|
+
* An outside press closes it in the CAPTURE phase, so a control underneath does not also act on the
|
|
321
|
+
* click that dismissed the menu — the same rule a native context menu follows.
|
|
322
|
+
*
|
|
323
|
+
* The press that OPENED it must not close it, and that is why the guards are bound a TASK LATE (see
|
|
324
|
+
* `scheduleBind`) rather than during the opening dispatch. This was a timestamp comparison until
|
|
325
|
+
* 2026-10-12 — `event.timeStamp <= openedAt`, with `openedAt` from `performance.now()` — and it was
|
|
326
|
+
* fragile in exactly the way a clock comparison is: `timeStamp` is COARSENED (Chrome rounds it to
|
|
327
|
+
* 100 µs), so two events in the same instant can carry an equal stamp and a genuine outside press
|
|
328
|
+
* could be mistaken for the opening one, while a probe that dispatched a press a few microseconds
|
|
329
|
+
* after `open()` could be read as the opening press and pass or fail on rounding alone. Deferring the
|
|
330
|
+
* bind asks the question structurally instead: while the opening press is still dispatching, the
|
|
331
|
+
* listener does not exist yet.
|
|
332
|
+
*/
|
|
333
|
+
function onDocPointerDown(event) {
|
|
334
|
+
if (event && event.target && element.contains(event.target)) return;
|
|
335
|
+
close();
|
|
336
|
+
}
|
|
337
|
+
function onKeyDown(event) {
|
|
338
|
+
if (!event) return;
|
|
339
|
+
if (event.key === 'Escape') {
|
|
340
|
+
if (event.preventDefault) event.preventDefault();
|
|
341
|
+
close();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
345
|
+
if (event.preventDefault) event.preventDefault();
|
|
346
|
+
focusStep(event.key === 'ArrowDown' ? 1 : -1);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function onBlur() { close(); }
|
|
350
|
+
|
|
351
|
+
/** Move the focus to the next enabled item, wrapping. */
|
|
352
|
+
function focusStep(direction) {
|
|
353
|
+
var enabled = [];
|
|
354
|
+
for (var i = 0; i < nodes.length; i += 1) if (!nodes[i].disabled) enabled.push(nodes[i]);
|
|
355
|
+
if (!enabled.length) return;
|
|
356
|
+
var at = enabled.indexOf(doc.activeElement);
|
|
357
|
+
var next = at < 0
|
|
358
|
+
? (direction > 0 ? 0 : enabled.length - 1)
|
|
359
|
+
: (at + direction + enabled.length) % enabled.length;
|
|
360
|
+
try { enabled[next].focus(); } catch (error) { /* a non-focusable window: pointer only */ }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
var listenersBound = false;
|
|
364
|
+
function bind(active) {
|
|
365
|
+
if (!doc || typeof doc.addEventListener !== 'function') return;
|
|
366
|
+
if (active === listenersBound) return;
|
|
367
|
+
listenersBound = active;
|
|
368
|
+
var method = active ? 'addEventListener' : 'removeEventListener';
|
|
369
|
+
doc[method]('pointerdown', onDocPointerDown, true);
|
|
370
|
+
doc[method]('mousedown', onDocPointerDown, true);
|
|
371
|
+
doc[method]('keydown', onKeyDown);
|
|
372
|
+
var view = doc.defaultView;
|
|
373
|
+
if (view && typeof view[method] === 'function') view[method]('blur', onBlur);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Bind the guards on the NEXT task, and never during the dispatch that opened the menu.
|
|
378
|
+
*
|
|
379
|
+
* This is the whole of "the opening press cannot close it". Tasks are ordered, so the timer queued
|
|
380
|
+
* here runs before any later input event: the user's next press still closes the menu, while a press
|
|
381
|
+
* from the very interaction that opened it is already gone by the time the listener exists. A window
|
|
382
|
+
* with no timers (a bare test document) binds immediately — there is no dispatch in flight to protect.
|
|
383
|
+
*/
|
|
384
|
+
var bindTimer = 0;
|
|
385
|
+
function scheduleBind() {
|
|
386
|
+
cancelBind();
|
|
387
|
+
var view = doc && doc.defaultView;
|
|
388
|
+
if (!view || typeof view.setTimeout !== 'function') { bind(true); return; }
|
|
389
|
+
bindTimer = view.setTimeout(function () {
|
|
390
|
+
bindTimer = 0;
|
|
391
|
+
bind(true);
|
|
392
|
+
}, 0);
|
|
393
|
+
}
|
|
394
|
+
function cancelBind() {
|
|
395
|
+
if (!bindTimer) return;
|
|
396
|
+
var view = doc && doc.defaultView;
|
|
397
|
+
if (view && typeof view.clearTimeout === 'function') view.clearTimeout(bindTimer);
|
|
398
|
+
bindTimer = 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Show the menu, at the origin the surface placed it at last (or 0,0).
|
|
403
|
+
*
|
|
404
|
+
* Deliberately NOT positioned here: the surface measures it and then places it, because only the
|
|
405
|
+
* surface knows what coordinate space the menu has to fit in. The element is measured hidden-visible
|
|
406
|
+
* (`visibility`, not `display`), so the first paint is already at the final position instead of
|
|
407
|
+
* flashing once at the top-left corner.
|
|
408
|
+
*/
|
|
409
|
+
function open() {
|
|
410
|
+
render();
|
|
411
|
+
element.style.visibility = 'hidden';
|
|
412
|
+
element.hidden = false;
|
|
413
|
+
scheduleBind();
|
|
414
|
+
if (typeof options.onOpen === 'function') options.onOpen();
|
|
415
|
+
return size();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** The menu's measured size, for a surface that has to fit it into a frame. */
|
|
419
|
+
function size() {
|
|
420
|
+
return {
|
|
421
|
+
width: element.offsetWidth || 0,
|
|
422
|
+
height: element.offsetHeight || 0,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Put the menu's top-left at a VIEWPORT coordinate, and make it visible. */
|
|
427
|
+
function place(left, top) {
|
|
428
|
+
element.style.left = Math.round(left) + 'px';
|
|
429
|
+
element.style.top = Math.round(top) + 'px';
|
|
430
|
+
element.style.visibility = '';
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function close() {
|
|
434
|
+
// Even when the menu is already away: a pending guard-bind has to be called off, or a closed menu
|
|
435
|
+
// would put document listeners up on the next task that nothing owns any more.
|
|
436
|
+
cancelBind();
|
|
437
|
+
if (element.hidden) return;
|
|
438
|
+
element.hidden = true;
|
|
439
|
+
bind(false);
|
|
440
|
+
if (typeof options.onClose === 'function') options.onClose();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function choose(id) {
|
|
444
|
+
close();
|
|
445
|
+
onChoose(id);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
element: element,
|
|
450
|
+
open: open,
|
|
451
|
+
place: place,
|
|
452
|
+
close: close,
|
|
453
|
+
isOpen: function () { return element.hidden !== true; },
|
|
454
|
+
size: size,
|
|
455
|
+
/** Point the 切换 row the other way; the next open rebuilds the labels. */
|
|
456
|
+
setSurface: function (next) { toSurface = next === 'app' ? 'app' : 'window'; },
|
|
457
|
+
/** Whether that row may be used at all (false in debug mode). */
|
|
458
|
+
setSurfaceAllowed: function (allowed) { surfaceAllowed = allowed !== false; },
|
|
459
|
+
/**
|
|
460
|
+
* Which way a SWITCH row is thrown, read at OPEN time by the surface that owns the preference.
|
|
461
|
+
*
|
|
462
|
+
* Per id rather than one flag, because the surfaces do NOT share these: 重力 is independent on the two
|
|
463
|
+
* pets (operator, 2026-10-12), and one boolean here would be the very coupling that request rules out.
|
|
464
|
+
*/
|
|
465
|
+
setChecked: function (id, value) { checkedState[id] = value === true; },
|
|
466
|
+
/** What the menu is showing, for a probe: `[{id, label, disabled, checked}]`. */
|
|
467
|
+
items: function () { return built.slice(); },
|
|
468
|
+
destroy: function () {
|
|
469
|
+
cancelBind();
|
|
470
|
+
bind(false);
|
|
471
|
+
if (element.parentNode) element.parentNode.removeChild(element);
|
|
472
|
+
},
|
|
473
|
+
strings: text,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
var api = {
|
|
478
|
+
createMenu: createMenu,
|
|
479
|
+
injectStyle: injectStyle,
|
|
480
|
+
MENU_STYLE: STYLE,
|
|
481
|
+
MENU_ITEMS: ITEMS,
|
|
482
|
+
MENU_LABELS: LABELS,
|
|
483
|
+
strings: strings,
|
|
484
|
+
clamp: clamp,
|
|
485
|
+
};
|
|
486
|
+
if (typeof globalThis !== 'undefined') globalThis.__VRMPET3D_MENU__ = api;
|
|
487
|
+
})();
|
|
488
|
+
|
|
489
|
+
// --- DSH client module registration (appended by scripts/client-chunks.mjs) ---
|
|
490
|
+
// The id is the full module-table key. The loader reads it unconditionally,
|
|
491
|
+
// so it cannot be omitted, and adding a `chunk` field as well would append the
|
|
492
|
+
// file name a second time.
|
|
493
|
+
if (typeof window !== 'undefined' && window.__ModuleLoader__ &&
|
|
494
|
+
typeof window.__ModuleLoader__.load === 'function') {
|
|
495
|
+
window.__ModuleLoader__.load({
|
|
496
|
+
id: 'dsh-plugin-vrmpet3d/client.menu.js',
|
|
497
|
+
factory: function () {
|
|
498
|
+
return globalThis.__VRMPET3D_MENU__;
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
}
|