dsh-plugin-vrmpet3d 0.1.0 → 0.1.2

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.
@@ -30,47 +30,73 @@
30
30
  var MODEL_ACCEPT = '.vrm,.glb,.gltf';
31
31
  var MODEL_PATTERN = /\.(vrm|glb|gltf)$/i;
32
32
 
33
+ /**
34
+ * This surface's own wording, in the two languages it draws in: `TEXT.zh` is the Chinese half, and the English
35
+ * twin is the literal {@link resolveText} returns for `'en'`.
36
+ *
37
+ * TWO LITERALS, one per language, rather than two columns of one table — because each half has to be written as
38
+ * its own language rather than as a translation of the other, which is also why `lib/menu.js` spells its
39
+ * `LABELS` out twice. Both halves are written in FULL: a half that is merely a gloss on the other is what makes
40
+ * a half-translated interface.
41
+ *
42
+ * THE TWO KEY SETS MUST BE IDENTICAL. A key the Chinese half has and the English does not falls back to Chinese
43
+ * mid-sentence (`core.textFor`), which is the defect a suite asserts against — and that includes the
44
+ * FUNCTION-valued entries: they stay functions in both halves because that is what lets the English put its
45
+ * words in its own order (`creditAuthor(author)`) rather than in the Chinese sentence's order.
46
+ */
33
47
  var TEXT = {
34
- dropTitle: '点击选择模型文件',
35
- dropSub: '或把 ' + MODEL_ACCEPT.split(',').join(' / ') + ' 拖到这里',
36
- reading: '正在读取模型列表…',
37
- listFailed: '模型列表读取失败。',
38
- empty: '还没有可用的模型。',
39
- count: function (total, imported) {
40
- return '共 ' + total + ' 个模型' + (imported ? '(其中 ' + imported + ' 个是导入的)' : '') + '。';
48
+ zh: {
49
+ dropTitle: '点击选择模型文件',
50
+ dropSub: '或把 ' + MODEL_ACCEPT.split(',').join(' / ') + ' 拖到这里',
51
+ reading: '正在读取模型列表…',
52
+ listFailed: '模型列表读取失败。',
53
+ empty: '还没有可用的模型。',
54
+ gridLabel: '现有模型',
55
+ count: function (total, imported) {
56
+ return '共 ' + total + ' 个模型' + (imported ? '(其中 ' + imported + ' 个是导入的)' : '') + '。';
57
+ },
58
+ tagBundled: '自带',
59
+ tagUser: '已导入',
60
+ using: '使用中',
61
+ remove: '移除这个导入的模型',
62
+ importing: '正在导入模型…',
63
+ importingFile: function (name) { return '正在导入 ' + name + '…'; },
64
+ imported: function (name) { return '已导入并切换:' + name; },
65
+ importedNoSwitch: function (name) { return '已导入:' + name; },
66
+ importFailed: '导入失败:',
67
+ switched: function (name) { return '已切换模型:' + name; },
68
+ switching: function (name) { return '正在切换模型:' + name + '…'; },
69
+ switchingBusy: '正在切换中,等这一只加载完再点。',
70
+ switchingSlow: '仍在加载。',
71
+ switchingTimeout: function (name) { return '切换超时:' + name + ' 可能没能加载,看看桌宠上的提示。'; },
72
+ noModel: '这个文件不是模型(只接受 .vrm / .glb / .gltf)。',
73
+ dropBusy: '正在导入,请等它读完。',
74
+ noFetchUpload: '这个页面不能上传文件。',
75
+ removed: '已移除插件复制的那一份。',
76
+ removeFailed: '移除失败:',
77
+ /**
78
+ * 自带模型的来源与作者 — the operator's own requirement (2026-10-13: 「需要注明来源和作者」).
79
+ *
80
+ * The WORDS live here with every other string; the FACTS come from the host, which is the only writer of a
81
+ * catalogue entry's `credit` (`BUNDLED_MODEL_CREDITS` in `lib/index.js`). A credit typed into this file as
82
+ * well would be a second copy of who made what.
83
+ */
84
+ creditTitle: '插件自带模型',
85
+ creditName: function (title, file) { return title ? title + '(' + file + ')' : file; },
86
+ creditAuthor: function (author) { return '作者:' + author; },
87
+ creditLicense: function (license) { return '授权:' + license; },
88
+ creditSource: function (source) { return '来源:' + source; },
41
89
  },
42
- tagBundled: '自带',
43
- tagUser: '已导入',
44
- using: '使用中',
45
- remove: '移除这个导入的模型',
46
- importing: '正在导入模型…',
47
- importingFile: function (name) { return '正在导入 ' + name + '…'; },
48
- imported: function (name) { return '已导入并切换:' + name; },
49
- importedNoSwitch: function (name) { return '已导入:' + name; },
50
- importFailed: '导入失败:',
51
- switched: function (name) { return '已切换模型:' + name; },
52
- switching: function (name) { return '正在切换模型:' + name + '…'; },
53
- switchingBusy: '正在切换中,等这一只加载完再点。',
54
- switchingSlow: '仍在加载。',
55
- switchingTimeout: function (name) { return '切换超时:' + name + ' 可能没能加载,看看桌宠上的提示。'; },
56
- noModel: '这个文件不是模型(只接受 .vrm / .glb / .gltf)。',
57
- dropBusy: '正在导入,请等它读完。',
58
- removed: '已移除插件复制的那一份。',
59
- removeFailed: '移除失败:',
60
- /**
61
- * 自带模型的来源与作者 — the operator's own requirement (2026-10-13: 「需要注明来源和作者」).
62
- *
63
- * The WORDS live here with every other string; the FACTS come from the host, which is the only writer of a
64
- * catalogue entry's `credit` (`BUNDLED_MODEL_CREDITS` in `lib/index.js`). A credit typed into this file as
65
- * well would be a second copy of who made what.
66
- */
67
- creditTitle: '插件自带模型',
68
- creditName: function (title, file) { return title ? title + '(' + file + ')' : file; },
69
- creditAuthor: function (author) { return '作者:' + author; },
70
- creditLicense: function (license) { return '授权:' + license; },
71
- creditSource: function (source) { return '来源:' + source; },
72
90
  };
73
91
 
92
+ /**
93
+ * This surface's words in one of its two languages — the accessor every caller goes through, and what
94
+ * `__VRMPET3D_MODEL_PICKER__.strings` exposes to a suite.
95
+ *
96
+ * `'zh'` (and anything that is not `'en'`, which includes a caller that passed no language at all) gets the
97
+ * Chinese half, the default this component has always had. `'auto'` is NOT resolved here: the caller knows what
98
+ * the environment says and passes the answer in, so a component that never sees a store cannot guess wrong.
99
+ */
74
100
  function resolveText(language) {
75
101
  if (language === 'en') {
76
102
  return {
@@ -79,8 +105,9 @@
79
105
  reading: 'Reading the model list…',
80
106
  listFailed: 'Could not read the model list.',
81
107
  empty: 'No models available yet.',
108
+ gridLabel: 'Available models',
82
109
  count: function (total, imported) {
83
- return total + ' models' + (imported ? ' (' + imported + ' imported)' : '') + '.';
110
+ return total + (total === 1 ? ' model' : ' models') + (imported ? ' (' + imported + ' imported)' : '') + '.';
84
111
  },
85
112
  tagBundled: 'built-in',
86
113
  tagUser: 'imported',
@@ -96,18 +123,19 @@
96
123
  switchingBusy: 'A switch is already running — wait for this one to load.',
97
124
  switchingSlow: 'Still loading.',
98
125
  switchingTimeout: function (name) { return 'The switch timed out: ' + name + ' may not have loaded (check the pet for a message).'; },
99
- noModel: 'That is not a model this plugin loads (.vrm / .glb / .gltf only).',
100
- dropBusy: 'An import is already running.',
126
+ noModel: 'That file is not a model this plugin can load (.vrm / .glb / .gltf only).',
127
+ dropBusy: 'An import is already running — wait for it to finish.',
128
+ noFetchUpload: 'This page cannot upload files.',
101
129
  removed: 'Removed the plugin\'s own copy.',
102
130
  removeFailed: 'Could not remove it: ',
103
- creditTitle: 'Models this plugin ships',
131
+ creditTitle: 'Bundled models',
104
132
  creditName: function (title, file) { return title ? title + ' (' + file + ')' : file; },
105
133
  creditAuthor: function (author) { return 'Author: ' + author; },
106
- creditLicense: function (license) { return 'Terms: ' + license; },
134
+ creditLicense: function (license) { return 'License: ' + license; },
107
135
  creditSource: function (source) { return 'Source: ' + source; },
108
136
  };
109
137
  }
110
- return TEXT;
138
+ return TEXT.zh;
111
139
  }
112
140
 
113
141
  function element(doc, tag, className, value) {
@@ -133,7 +161,9 @@
133
161
  * @param {object} options
134
162
  * document the document to build in (defaults to the global one)
135
163
  * mount the element to fill — the component owns everything inside it
136
- * language 'zh' (default) or 'en'
164
+ * language 'zh' (default) or 'en' — the language it is BUILT in; `api.setLanguage` changes it later
165
+ * without a rebuild, which is how a caller whose language comes from the settings store
166
+ * (the 切换模型 page) follows a change while the grid is on screen
137
167
  * bridge the launcher bridge, or null to force the browser path (defaults to
138
168
  * `globalThis.vrmpet3dDesktop`); a page with no bridge still gets a working picker
139
169
  * fetchImpl fetch override, for a probe
@@ -154,7 +184,16 @@
154
184
  var mount = settings.mount;
155
185
  if (!doc || !mount) throw new Error('createModelPicker needs a document and a mount element');
156
186
 
157
- var text = resolveText(settings.language);
187
+ /**
188
+ * The language this picker draws in, and its table.
189
+ *
190
+ * Resolved ONCE here, from `options.language` — 'zh' unless the caller says 'en', which is the default this
191
+ * option has always had (the wizard mounts with 'zh'). A caller whose language can change while the picker is on
192
+ * screen calls {@link api.setLanguage} instead of rebuilding: the 切换模型 page is exactly that caller, because
193
+ * its language comes from the settings store it polls.
194
+ */
195
+ var language = settings.language === 'en' ? 'en' : 'zh';
196
+ var text = resolveText(language);
158
197
  var onSwitch = typeof settings.onSwitch === 'function' ? settings.onSwitch : function () {};
159
198
  var onStatus = typeof settings.onStatus === 'function' ? settings.onStatus : function () {};
160
199
  var doFetch = settings.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
@@ -212,7 +251,7 @@
212
251
  input.hidden = true;
213
252
 
214
253
  var gridHead = element(doc, 'div', 'model-grid-head');
215
- var gridLabel = element(doc, 'span', 'k', '现有模型');
254
+ var gridLabel = element(doc, 'span', 'k', text.gridLabel);
216
255
  var gridCount = element(doc, 'span', 'note model-grid-count');
217
256
  gridHead.appendChild(gridLabel);
218
257
  gridHead.appendChild(gridCount);
@@ -385,7 +424,7 @@
385
424
  }
386
425
 
387
426
  function requestJson(path, body) {
388
- if (!doFetch) return Promise.reject(new Error('no fetch in this document'));
427
+ if (!doFetch) return Promise.reject(new Error(text.noFetchUpload));
389
428
  return doFetch(path, {
390
429
  method: 'POST',
391
430
  credentials: 'omit',
@@ -494,6 +533,32 @@
494
533
  });
495
534
  }
496
535
 
536
+ /**
537
+ * Draw everything again in the CURRENT `text` — the whole of a language change.
538
+ *
539
+ * Three static labels are written at construction (the drop zone's two lines and the grid's heading) and one
540
+ * more is stateful (the progress bar, whose two lines are set when a switch STARTS). Everything else already
541
+ * comes out of `render()`, so repainting the grid covers the tiles, the tags, the 使用中 badge, the count, the
542
+ * empty note and the credit lines — and the bar is rewritten here so a switch that is in flight says the same
543
+ * thing in the new language instead of freezing in the old one until it settles.
544
+ */
545
+ function relabel() {
546
+ dropTitle.textContent = text.dropTitle;
547
+ dropSub.textContent = text.dropSub;
548
+ gridLabel.textContent = text.gridLabel;
549
+ if (switching) {
550
+ progressText.textContent = text.switching(switching.name);
551
+ progressNote.textContent = switching.slow ? text.switchingSlow : '';
552
+ progress.setAttribute('aria-valuetext', text.switching(switching.name));
553
+ // And the CALLER's line, which is where the same sentence usually lives: without this the bar would say
554
+ // 「正在切换模型…」 while the status line above it stayed in the language the switch STARTED in. The
555
+ // component owns this text, so it is the component that says it again — through the same channel it was
556
+ // first said on.
557
+ onStatus(text.switching(switching.name));
558
+ }
559
+ render();
560
+ }
561
+
497
562
  /**
498
563
  * Write out 来源 and 作者 for every bundled model the host sent a credit for.
499
564
  *
@@ -621,7 +686,7 @@
621
686
  return Promise.resolve(null);
622
687
  }
623
688
  if (!doFetch) {
624
- onStatus(text.importFailed + 'no fetch in this document', 'warn');
689
+ onStatus(text.noFetchUpload, 'warn');
625
690
  return Promise.resolve(null);
626
691
  }
627
692
  setBusy(true, text.importingFile(name));
@@ -690,7 +755,7 @@
690
755
  function pickNative() {
691
756
  var chooser = bridge && typeof bridge.pickModelFile === 'function' ? bridge.pickModelFile : null;
692
757
  if (!chooser) return false;
693
- Promise.resolve(chooser()).then(function (chosen) { return chosen ? importPath(chosen) : null; });
758
+ Promise.resolve(chooser(language)).then(function (chosen) { return chosen ? importPath(chosen) : null; });
694
759
  return true;
695
760
  }
696
761
 
@@ -750,6 +815,30 @@
750
815
  * handler calls it — see `settleSwitch`.
751
816
  */
752
817
  setCurrent: function () { pendingId = null; render(); settleSwitch(); },
818
+ /**
819
+ * Draw in another language, WITHOUT being rebuilt.
820
+ *
821
+ * `options.language` is fixed at construction, and that is right for a wizard step whose language was settled
822
+ * before the step was reached. The 切换模型 page is the other case: its language comes from the settings store
823
+ * it polls, so it can change while the picker is on screen. Rebuilding would be the easy answer and the wrong
824
+ * one — it would throw away a switch's progress bar, its pending tile and the catalogue it already has, so a
825
+ * language change would cost a second round trip and lose the user's click. `relabel()` is what this changes:
826
+ * the same nodes, the same state, new words.
827
+ *
828
+ * A language this component does not know ('auto', undefined, a typo) is IGNORED rather than treated as
829
+ * Chinese: the picker keeps the table it has, so a caller that passes the store's raw preference through
830
+ * cannot flip a page to Chinese by accident.
831
+ *
832
+ * @returns {boolean} whether the picker redrew — false for "same language" and for "not a language I know"
833
+ */
834
+ setLanguage: function (next) {
835
+ var wanted = next === 'en' ? 'en' : (next === 'zh' ? 'zh' : null);
836
+ if (!wanted || wanted === language) return false;
837
+ language = wanted;
838
+ text = resolveText(language);
839
+ relabel();
840
+ return true;
841
+ },
753
842
  /** Adopt a catalog payload a caller already has (the guide's own GET, a probe). */
754
843
  apply: apply,
755
844
  /** Import a path (the launcher's dialog) or a File (a drop, a browser input). */
@@ -766,6 +855,7 @@
766
855
  }),
767
856
  total: models.length,
768
857
  imported: imported.length,
858
+ language: language,
769
859
  current: currentId(),
770
860
  busy: busy,
771
861
  switching: switching ? switching.id : null,
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>vrmpet3d · 切换模型</title>
6
+ <title data-i18n="title">vrmpet3d · 切换模型</title>
7
7
  <meta name="color-scheme" content="dark light">
8
8
  <!--
9
9
  切换模型 — the model picker's own page (`/vrmpet3d/picker`), opened from either pet's right-click menu.
@@ -21,15 +21,21 @@
21
21
  <link rel="stylesheet" href="/vrmpet3d/window/lib/pages.css">
22
22
  </head>
23
23
  <body data-page="picker">
24
+ <!--
25
+ The page's own five pieces of text carry `data-i18n` and keep the Chinese inline: `lib/picker-page.js`
26
+ resolves the language and calls `core.applyI18n`, and a page whose script never ran still reads in one
27
+ language rather than showing empty nodes. `html lang` is that same Chinese default, and the script rewrites it
28
+ once it knows better — it is NOT what the language is resolved from (see `core.browserLanguageHints`).
29
+ -->
24
30
  <header class="page-header">
25
- <div class="eyebrow">VRMPET3D · MODELS</div>
26
- <h1>切换模型</h1>
27
- <div id="status" role="status" aria-live="polite">正在读取…</div>
31
+ <div class="eyebrow" data-i18n="eyebrow">VRMPET3D · MODELS</div>
32
+ <h1 data-i18n="heading">切换模型</h1>
33
+ <div id="status" role="status" aria-live="polite" data-i18n="statusLoading">正在读取…</div>
28
34
  </header>
29
35
 
30
36
  <main>
31
37
  <section class="settings-section" id="settings-0">
32
- <h2>模型</h2>
38
+ <h2 data-i18n="sectionModels">模型</h2>
33
39
  <div class="card" id="picker-mount"></div>
34
40
  </section>
35
41
  </main>
@@ -40,20 +46,40 @@
40
46
  -->
41
47
  <script>
42
48
  (function () {
49
+ /**
50
+ * The watchdog's OWN words, in its own two languages, because it runs BEFORE core.js and can use neither
51
+ * `core.textFor` nor the settings store. `navigator.language` is the whole of its rule: the document's own
52
+ * `lang` is the Chinese default every page here ships, so reading it would only ever say "Chinese".
53
+ */
54
+ var WORDS = {
55
+ zh: {
56
+ pageError: '页面脚本出错:',
57
+ stuckMissing: '初始化卡住:model-picker.js 没有加载(检查 /vrmpet3d/window/lib/model-picker.js 是否 404)。',
58
+ stuckSlow: '初始化卡住:脚本已加载但没有完成初始化(看看窗口的控制台,或宿主是否提供 /vrmpet3d/models 与 /vrmpet3d/settings)。',
59
+ },
60
+ en: {
61
+ pageError: 'Script error: ',
62
+ stuckMissing: 'Stuck on startup: model-picker.js did not load — check whether /vrmpet3d/window/lib/model-picker.js is a 404.',
63
+ stuckSlow: 'Stuck on startup: the script loaded but never finished — check the window console, and whether the host serves /vrmpet3d/models and /vrmpet3d/settings.',
64
+ },
65
+ };
66
+ var language = /^(zh|cn|chinese)/i.test(navigator.language || '') ? 'zh' : 'en';
67
+ function t(key) { return WORDS[language][key]; }
43
68
  function show(message) {
44
69
  var status = document.getElementById("status");
45
70
  if (status) { status.textContent = message; status.className = "warn"; }
46
71
  }
47
72
  window.addEventListener("error", function (event) {
48
- show("页面脚本出错:" + (event.message || event.type) + (event.filename ? " (" + event.filename + ")" : ""));
73
+ var where = event.filename ? (language === 'zh' ? ' (' + event.filename + ')' : ' (' + event.filename + ')') : '';
74
+ show(t('pageError') + (event.message || event.type) + where);
49
75
  }, true);
50
76
  window.setTimeout(function () {
51
77
  if (window.__VRMPET3D_PICKER_STATE__) return;
52
78
  if (!window.__VRMPET3D_MODEL_PICKER__) {
53
- show("初始化卡住:model-picker.js 没有加载(检查 /vrmpet3d/window/lib/model-picker.js 是否 404)。");
79
+ show(t('stuckMissing'));
54
80
  return;
55
81
  }
56
- show("初始化卡住:脚本已加载但没有完成初始化(看看窗口的控制台,或宿主是否提供 /vrmpet3d/models 与 /vrmpet3d/settings)。");
82
+ show(t('stuckSlow'));
57
83
  }, 2000);
58
84
  })();
59
85
  </script>
@@ -11,6 +11,12 @@
11
11
  * (`hasSnapshot` in `lib/config-page.js`): nothing is written before the first read, or a page that booted into
12
12
  * defaults would overwrite everything the user configured.
13
13
  *
14
+ * AND THE LANGUAGE IS THE SECOND THING THIS PAGE OWNS. The two settings pages pick theirs when they boot, and so
15
+ * does the guide; this page can be sitting open while the user changes 界面语言 in 通用设置, because the setting
16
+ * lives in the same store the page already polls. So every snapshot re-resolves it (`resolveLanguage`), the
17
+ * page's own lines are written in it (`t`), and the mounted component is RE-LABELLED rather than rebuilt — see
18
+ * `applyLanguage` for why that is not just an optimisation.
19
+ *
14
20
  * For the browser probe and the launcher's self-test: `window.__VRMPET3D_PICKER_STATE__`.
15
21
  */
16
22
  (function () {
@@ -19,6 +25,46 @@
19
25
  var core = globalThis.__VRMPET3D_CORE__;
20
26
  var pickerApi = globalThis.__VRMPET3D_MODEL_PICKER__;
21
27
 
28
+ /**
29
+ * This page's own words, in both languages — `{ zh, en }`, the shape `core.textFor` and `core.applyI18n` take.
30
+ *
31
+ * The first five are the ones the MARKUP also carries: `lib/picker-page.html` marks its title, eyebrow, heading,
32
+ * status placeholder and section heading with `data-i18n`, and keeps the Chinese inline, so a page whose script
33
+ * never ran still says something in one language rather than showing nothing. The rest exist only here, because
34
+ * they are the lines THIS SCRIPT writes (the six status/error lines), and there is no markup to fall back to.
35
+ *
36
+ * THE TWO HALVES MUST HOLD THE SAME KEYS: a key the English half lacks falls back to Chinese mid-page
37
+ * (`core.textFor`), which is the half-translated interface this table exists to prevent.
38
+ */
39
+ var TEXT = {
40
+ zh: {
41
+ title: 'vrmpet3d · 切换模型',
42
+ eyebrow: 'VRMPET3D · MODELS',
43
+ heading: '切换模型',
44
+ statusLoading: '正在读取…',
45
+ sectionModels: '模型',
46
+ reading: '正在读取模型列表…',
47
+ settingsRead: '已读取当前设置。',
48
+ noHostSync: '这个宿主没有提供设置接口,切换只在本页生效。',
49
+ noCoreSettings: '这个版本的 core 没有设置接口,页面只能展示默认值。',
50
+ missingPicker: '页面脚本缺失:model-picker.js 没有加载。',
51
+ missingMount: '页面结构缺失:找不到 #picker-mount。',
52
+ },
53
+ en: {
54
+ title: 'vrmpet3d · Switch model',
55
+ eyebrow: 'VRMPET3D · MODELS',
56
+ heading: 'Switch model',
57
+ statusLoading: 'Reading…',
58
+ sectionModels: 'Model',
59
+ reading: 'Reading the model list…',
60
+ settingsRead: 'Settings loaded.',
61
+ noHostSync: 'This host has no settings API, so a switch only affects this page.',
62
+ noCoreSettings: 'This build of core has no settings API, so the page can only show defaults.',
63
+ missingPicker: 'Page script missing: model-picker.js did not load.',
64
+ missingMount: 'Page markup missing: #picker-mount was not found.',
65
+ },
66
+ };
67
+
22
68
  /** The first host snapshot is the permission to write. */
23
69
  var hasSnapshot = false;
24
70
  var preferences = core ? core.cleanPreferences({}) : {};
@@ -26,14 +72,67 @@
26
72
  var picker = null;
27
73
  /** Which model a SURFACE reports it is showing (`snapshot.live`), which is what ends the progress bar. */
28
74
  var liveModel = null;
75
+ /**
76
+ * The language this page draws in — `'zh'` or `'en'`, never `'auto'` (see `resolveLanguage`).
77
+ *
78
+ * `null` until the first resolution, which is what makes the boot pass the one that fills the markup in.
79
+ */
80
+ var language = null;
29
81
 
30
82
  function el(id) { return document.getElementById(id); }
31
83
 
32
- function setStatus(text, kind) {
84
+ /**
85
+ * The line on `#status` right now: `{key}` when it is one of THIS page's own, `{text}` when the picker or the
86
+ * bootstrap watchdog wrote it, and `null` before anything has been written.
87
+ *
88
+ * The distinction is what a language change needs. One of this page's lines can be said again in the new
89
+ * language because it is stored as a KEY; a finished sentence from the component cannot be re-derived, so it is
90
+ * left exactly as it is rather than replaced by a translation of the placeholder — which would read as "reading
91
+ * the model list" while a model is in fact still loading.
92
+ */
93
+ var statusLine = null;
94
+ /** What this page last painted, so a line written by somebody ELSE (the watchdog) can be told apart from it. */
95
+ var paintedStatus = null;
96
+
97
+ function paintStatus() {
33
98
  var node = el('status');
34
- if (!node) return;
35
- node.textContent = text || '';
36
- node.className = kind === 'warn' ? 'warn' : '';
99
+ if (!node || !statusLine) return;
100
+ node.textContent = statusLine.key ? t(statusLine.key) : statusLine.text;
101
+ node.className = statusLine.kind === 'warn' ? 'warn' : '';
102
+ paintedStatus = node.textContent;
103
+ }
104
+
105
+ /** A line this page wrote, as the key it came from — so a later language change can write it again. */
106
+ function setStatusKey(key, kind) {
107
+ statusLine = { key: key, kind: kind };
108
+ paintStatus();
109
+ }
110
+
111
+ /** A finished line, from the picker's `onStatus`. */
112
+ function setStatus(message, kind) {
113
+ statusLine = { text: message || '', kind: kind };
114
+ paintStatus();
115
+ }
116
+
117
+ /** The markup's own placeholder — the one line in `#status` that a translation is allowed to replace. */
118
+ function isPlaceholder(text) {
119
+ return text === TEXT.zh.statusLoading || text === TEXT.en.statusLoading;
120
+ }
121
+
122
+ /**
123
+ * One of THIS page's own lines, in the language on screen.
124
+ *
125
+ * `core.textFor` when core is there — so the lookup rule (and the fallback to the other language for a gap) is
126
+ * the shared one rather than a second implementation of it — and the table itself when core is old enough not to
127
+ * have it. A key with no entry shows as the KEY rather than as a blank line, which is how a missing entry gets
128
+ * reported instead of silently disappearing.
129
+ */
130
+ function t(key) {
131
+ var words = core && typeof core.textFor === 'function'
132
+ ? core.textFor(TEXT, language)
133
+ : (language === 'en' ? TEXT.en : TEXT.zh);
134
+ var value = words[key];
135
+ return typeof value === 'string' ? value : key;
37
136
  }
38
137
 
39
138
  /**
@@ -57,10 +156,80 @@
57
156
  return typeof preferences.modelId === 'string' ? preferences.modelId : '';
58
157
  }
59
158
 
159
+ /**
160
+ * Which language this page draws in, from the USER's setting and this surface's own environment.
161
+ *
162
+ * `preferences.language` is the store's answer — `'auto' | 'zh' | 'en'` (`core.LANGUAGE_SETTINGS`), which
163
+ * `core.cleanPreferences` passes through — so a choice made in 通用设置, in the guide or by another window
164
+ * reaches this page on its next snapshot. `'auto'` falls to `core.browserLanguageHints()`, which asks the
165
+ * browser BEFORE the document's own `lang`: every page in this plugin ships `<html lang="zh">`, so a page that
166
+ * read its own markup first could never auto-detect English.
167
+ *
168
+ * A core too old to know about languages keeps this page's own shipped answer, `'zh'` — the value this page
169
+ * passed unconditionally before it could resolve one, so an old host degrades to the old behaviour rather than
170
+ * failing to boot.
171
+ */
172
+ function resolveLanguage() {
173
+ if (core && typeof core.resolveLanguage === 'function') {
174
+ var hints = typeof core.browserLanguageHints === 'function' ? core.browserLanguageHints() : null;
175
+ return core.resolveLanguage(preferences.language, hints);
176
+ }
177
+ return 'zh';
178
+ }
179
+
180
+ /**
181
+ * Put the resolved language everywhere it is needed: the marked-up text, `html lang`, this page's own lines
182
+ * (through `t` at the point of use) and the mounted component.
183
+ *
184
+ * The component is RE-LABELLED, not rebuilt, and that is a real distinction rather than tidiness: a rebuild
185
+ * throws away a switch's progress bar, the tile it is pending on and the catalogue the grid already has — and a
186
+ * language change is exactly when a switch may be in flight, because the user has just come back from 通用设置
187
+ * to look at the window. `setLanguage` is the component's documented path for this; a component old enough not
188
+ * to have it keeps the language it was built with rather than being destroyed, so a language change can never
189
+ * cost the user the click they just made.
190
+ *
191
+ * The bootstrap watchdog writes into `#status` too, and it is the FIRST script on the page — it reports a
192
+ * core.js that threw before this script ran. `core.applyI18n` fills every marked node including that one, so a
193
+ * line the watchdog has already written is put back; the placeholder the markup ships is the only text the
194
+ * translation is allowed to replace.
195
+ *
196
+ * @returns {boolean} whether the language changed — false on the 700 ms polls that change nothing
197
+ */
198
+ function applyLanguage() {
199
+ var next = resolveLanguage();
200
+ if (next === language) return false;
201
+ language = next;
202
+ /**
203
+ * What is on the status line right now, if this page did NOT put it there.
204
+ *
205
+ * The bootstrap watchdog is the first script on the page and it writes into `#status` itself — it reports a
206
+ * core.js that threw while this script was still loading. `core.applyI18n` fills every marked node, that one
207
+ * included, so a line written by somebody else is captured here and put back: the placeholder the markup ships
208
+ * is the only text a translation may replace.
209
+ */
210
+ var status = el('status');
211
+ var foreign = status && !isPlaceholder(status.textContent) && status.textContent !== paintedStatus
212
+ ? status.textContent
213
+ : null;
214
+ if (core && typeof core.applyI18n === 'function') core.applyI18n(document, TEXT, language);
215
+ if (foreign !== null) {
216
+ status.textContent = foreign;
217
+ status.className = 'warn';
218
+ } else {
219
+ // One of this page's own lines — or the placeholder, which `applyI18n` has just translated.
220
+ paintStatus();
221
+ }
222
+ // The document says which language it is in, which is what a screen reader, a spell checker and a hyphenator
223
+ // read it for — and it is NOT the hint this page resolves from (that one was read before this line).
224
+ if (document.documentElement) document.documentElement.lang = language;
225
+ if (picker && typeof picker.setLanguage === 'function') picker.setLanguage(language);
226
+ return true;
227
+ }
228
+
60
229
  function patchPreferences(patch) {
61
230
  Object.keys(patch).forEach(function (key) { preferences[key] = patch[key]; });
62
231
  if (!hasSnapshot || !hostSync || typeof hostSync.push !== 'function') {
63
- setStatus('这个宿主没有提供设置接口,切换只在本页生效。', 'warn');
232
+ setStatusKey('noHostSync', 'warn');
64
233
  return;
65
234
  }
66
235
  hostSync.push({ preferences: patch });
@@ -79,7 +248,7 @@
79
248
 
80
249
  function watchSettings() {
81
250
  if (!core || typeof core.watchHostSettings !== 'function') {
82
- setStatus('这个版本的 core 没有设置接口,页面只能展示默认值。', 'warn');
251
+ setStatusKey('noCoreSettings', 'warn');
83
252
  return;
84
253
  }
85
254
  hostSync = core.watchHostSettings({
@@ -96,6 +265,10 @@
96
265
  onSnapshot: function (snapshot) {
97
266
  preferences = core.cleanPreferences(snapshot.preferences || {});
98
267
  hasSnapshot = true;
268
+ // The store's 界面语言 may have moved while this window was open (another window, or the guide), so the
269
+ // language is re-resolved BEFORE the lines below are written: a status line written first would be the one
270
+ // thing left in the old language.
271
+ applyLanguage();
99
272
  // Which model a pet reports it is SHOWING: the progress bar waits for this, not merely for the write.
100
273
  liveModel = liveModelIdOf(snapshot);
101
274
  // The value that wins is the STORE's, so a switch made from another window repaints this grid — and
@@ -103,24 +276,31 @@
103
276
  // what asks whether an in-flight switch has finished.
104
277
  if (picker) picker.setCurrent();
105
278
  // A snapshot that COMPLETES a switch must not have its line overwritten by this one.
106
- if (!(picker && picker.state().switching)) setStatus('已读取当前设置。');
279
+ if (!(picker && picker.state().switching)) setStatusKey('settingsRead');
107
280
  },
108
281
  }).start();
109
282
  }
110
283
 
111
284
  function boot() {
112
285
  var mount = el('picker-mount');
286
+ // The language FIRST, so that even the failure lines below are in the language the user asked for. The store
287
+ // has not answered yet, so this resolves `'auto'` from the browser; the first snapshot resolves it again and
288
+ // re-labels if the store says otherwise.
289
+ applyLanguage();
113
290
  if (!pickerApi || typeof pickerApi.createModelPicker !== 'function') {
114
- setStatus('页面脚本缺失:model-picker.js 没有加载。', 'warn');
291
+ setStatusKey('missingPicker', 'warn');
115
292
  return;
116
293
  }
117
294
  if (!mount) {
118
- setStatus('页面结构缺失:找不到 #picker-mount。', 'warn');
295
+ setStatusKey('missingMount', 'warn');
119
296
  return;
120
297
  }
121
298
  picker = pickerApi.createModelPicker({
122
299
  mount: mount,
123
- language: 'zh',
300
+ // The RESOLVED language, not the store's preference: the component owns two tables and no opinion about
301
+ // 'auto' (see `resolveText` in lib/model-picker.js). A change after this point goes through `setLanguage`,
302
+ // which is what `applyLanguage` calls.
303
+ language: language,
124
304
  currentModelId: modelId,
125
305
  // The live report is what makes the bar about the LOAD rather than about the write. See `liveModelIdOf`.
126
306
  liveModelId: function () { return liveModel; },
@@ -128,7 +308,7 @@
128
308
  onStatus: setStatus,
129
309
  });
130
310
  watchSettings();
131
- setStatus('正在读取模型列表…');
311
+ setStatusKey('reading');
132
312
  picker.refresh();
133
313
  }
134
314
 
@@ -139,6 +319,7 @@
139
319
  return {
140
320
  hasSnapshot: hasSnapshot,
141
321
  current: modelId(),
322
+ language: language,
142
323
  picker: picker ? picker.state() : null,
143
324
  };
144
325
  };