modelstage 0.0.0-stage → 0.5.28
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 +21 -0
- package/README.md +274 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +137 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +63 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +654 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +396 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +524 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +117 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +92 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +39 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +761 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// panel/slider.js —— 滑块控件的渲染与"位置↔数值"映射(从 view.js 抽出来)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独一个文件:滑块是唯一带**非线性映射**的控件(双段比例 + 吸附 + 刻度),
|
|
4
|
+
// 逻辑量比其它控件大;留在 view.js 里会把那个文件继续推长(超长文件审计不过)。
|
|
5
|
+
//
|
|
6
|
+
// 契约《禁止内联 CSS 与 JS》:
|
|
7
|
+
// · 颜色/尺寸/圆角/字体全部在 panel/panel.css(类名 + CSS 变量),这里**不写 element.style**;
|
|
8
|
+
// · 轨道与手柄用原生 `accent-color: var(--color-primary)` 跟随五套主题;
|
|
9
|
+
// · 中线刻度是伪元素,颜色由 CSS 侧的 var(--color-muted-foreground) 指定(accent-color 不影响伪元素)。
|
|
10
|
+
|
|
11
|
+
/** 允许的空值语义:0 值有专门文案时优先显示它(混合时长 0 秒 = "关") */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 滑块的"位置 ↔ 数值"映射表(非线性滑块的唯一权威)。
|
|
15
|
+
*
|
|
16
|
+
* 为什么速度要非线性:原生 range 是线性的,若 0.1~3 平铺,1.0 只落在 31% 处,
|
|
17
|
+
* 慢速段(0.1~1.0)被压成一小段,想细看慢动作很难拖准。
|
|
18
|
+
* 改成**双段比例**:左半 0~50 格 → 0.1~1.0(细调),右半 50~100 格 → 1.0~3.0(粗调),
|
|
19
|
+
* 1.0 恰好在正中间 —— 视觉上天然是"基准点",也更符合人对倍率的感知(不是线性的)。
|
|
20
|
+
*/
|
|
21
|
+
/** 速度滑块的取值域与双段划分(数值含义集中在常量里,映射函数只引用它们) */
|
|
22
|
+
export const SPEED_MIN = 0.1; // 最慢
|
|
23
|
+
export const SPEED_MID = 1; // 基准值:落在滑块正中
|
|
24
|
+
export const SPEED_MAX = 3; // 最快
|
|
25
|
+
export const SPEED_STEP = 0.1; // 对外声明的步进(读数是两位小数,实际由量化决定)
|
|
26
|
+
export const SPEED_DEFAULT = 1; // 默认倍率
|
|
27
|
+
export const MIX_MIN = 0; // 混合时长:0 = 不混合
|
|
28
|
+
export const MIX_MAX = 1; // 最长 1 秒
|
|
29
|
+
export const MIX_STEP = 0.05; // 混合时长步进
|
|
30
|
+
export const MIX_DEFAULT = 0.2; // 默认 0.2 秒(引擎默认过渡)
|
|
31
|
+
|
|
32
|
+
const POS_MIN = 0; // 滑块格位下界
|
|
33
|
+
const POS_MID = 50; // 正中 = SPEED_MID
|
|
34
|
+
const POS_MAX = 100; // 格位上界
|
|
35
|
+
const SNAP_RANGE = 3; // 正中 ±3 格内吸附到基准值
|
|
36
|
+
const QUANTIZE_STEPS = 20; // 按 1/20 = 0.05 归整
|
|
37
|
+
|
|
38
|
+
export const SLIDER_CURVES = {
|
|
39
|
+
speed: {
|
|
40
|
+
/** 位置(0~100 整数) → 数值 */
|
|
41
|
+
toValue: (p) => (p <= POS_MID
|
|
42
|
+
? SPEED_MIN + (p / POS_MID) * (SPEED_MID - SPEED_MIN)
|
|
43
|
+
: SPEED_MID + ((p - POS_MID) / (POS_MAX - POS_MID)) * (SPEED_MAX - SPEED_MID)),
|
|
44
|
+
/** 数值 → 位置(0~100 浮点,写入时取整) */
|
|
45
|
+
toPos: (v) => (v <= SPEED_MID
|
|
46
|
+
? ((v - SPEED_MIN) / (SPEED_MID - SPEED_MIN)) * POS_MID
|
|
47
|
+
: POS_MID + ((v - SPEED_MID) / (SPEED_MAX - SPEED_MID)) * (POS_MAX - POS_MID)),
|
|
48
|
+
/** 靠近正中(基准倍率)就吸附过去,避免"想回 1x 却总停在 0.97x" */
|
|
49
|
+
snap: { at: POS_MID, range: SNAP_RANGE },
|
|
50
|
+
// 按 0.05 归整:慢速段每格 ≈0.018,若按 0.1 归整会把 25% 处的 0.55 变成 0.6(与双段比例对不上);
|
|
51
|
+
// 0.05 既贴合映射、又保证下发/持久化里不会出现 1.2999999 这类长小数。
|
|
52
|
+
quantize: (v) => Math.round(v * QUANTIZE_STEPS) / QUANTIZE_STEPS,
|
|
53
|
+
precision: 1,
|
|
54
|
+
posMin: POS_MIN,
|
|
55
|
+
posMax: POS_MAX,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 取控件用的映射(无 curve 声明 ⇒ 线性,直接读 input 的 min/max/step)。
|
|
61
|
+
* @param {object} ctl schema 控件声明
|
|
62
|
+
*/
|
|
63
|
+
export function curveOf(ctl) {
|
|
64
|
+
return (ctl && ctl.curve && SLIDER_CURVES[ctl.curve]) || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 数值 → 滑块位置(线性控件原样返回)。
|
|
69
|
+
* @param {object} ctl
|
|
70
|
+
* @param {number} value 真实数值
|
|
71
|
+
* @returns {number} 位置(非线性时 0~100,线性时 min..max)
|
|
72
|
+
*/
|
|
73
|
+
export function posOf(ctl, value) {
|
|
74
|
+
const cv = curveOf(ctl);
|
|
75
|
+
return cv ? cv.toPos(Number(value)) : Number(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 位置 → 真实数值(非线性时做量化)。
|
|
80
|
+
* @param {object} ctl
|
|
81
|
+
* @param {number} pos 滑块位置
|
|
82
|
+
* @returns {number} 真实数值
|
|
83
|
+
*/
|
|
84
|
+
export function valueOfPos(ctl, pos) {
|
|
85
|
+
const cv = curveOf(ctl);
|
|
86
|
+
const raw = cv ? cv.toValue(Number(pos)) : Number(pos);
|
|
87
|
+
return cv ? cv.quantize(raw) : raw;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 读数格式化(按 schema 的 `format` 声明,不在这里猜单位)。
|
|
92
|
+
* format: 'x' → 1.5x(播速);'s' → 0.85s(时长);0 且声明了 zeroText → 显示该文案("关")
|
|
93
|
+
* @param {object} ctl
|
|
94
|
+
* @param {number} v
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function formatSliderValue(ctl, v) {
|
|
98
|
+
const n = Number(v);
|
|
99
|
+
if (ctl.zeroText && Number.isFinite(n) && n === 0) return ctl.zeroText;
|
|
100
|
+
const cv = curveOf(ctl);
|
|
101
|
+
const digits = cv && cv.precision !== undefined ? cv.precision : 2;
|
|
102
|
+
if (ctl.format === 's') return `${(Number.isFinite(n) ? n : 0).toFixed(2)}s`;
|
|
103
|
+
if (ctl.format === 'x') {
|
|
104
|
+
// 速度读数两位、裁掉末尾的 0,但**保留一位**(1.0x 不写成 1x、0.55x 不写成 0.6x):
|
|
105
|
+
// 读数是"真实生效值"的唯一可见出口,必须与内部值一致(0.05 归整后会出现 0.55 这类两位值)
|
|
106
|
+
const t = (Number.isFinite(n) ? n : 1).toFixed(2); // 1.50 / 0.55 / 1.00
|
|
107
|
+
return `${t.endsWith('0') ? t.slice(0, -1) : t}x`; // 1.5x / 0.55x / 1.0x
|
|
108
|
+
}
|
|
109
|
+
return Number.isFinite(n) ? n.toFixed(digits) : '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 渲染一个滑块控件。
|
|
114
|
+
*
|
|
115
|
+
* 结构**标签在左、当前值在右(等宽),滑块单独一行占满宽度**,正中一条基准刻度:
|
|
116
|
+
* ┌ 速度 1.5x ┐
|
|
117
|
+
* └ ─────●──┊───────────┘
|
|
118
|
+
*
|
|
119
|
+
* @param {object} ctl schema 控件声明(kind: 'slider')
|
|
120
|
+
* @param {object} ctx 面板上下文:{ isWired(id), onSetting(id, value) }
|
|
121
|
+
* @param {(tag: string, cls?: string, text?: string) => HTMLElement} el DOM 构造小工具(由 view.js 注入)
|
|
122
|
+
* @param {(node: HTMLElement, ctl: object, ctx: object) => void} markDisabled 置灰工具
|
|
123
|
+
* @returns {HTMLElement}
|
|
124
|
+
*/
|
|
125
|
+
/**
|
|
126
|
+
* 建滑块的原生 input(非线性曲线统一用 0~100 格;线性控件直接用 min/max/step)。
|
|
127
|
+
* @returns {HTMLInputElement}
|
|
128
|
+
*/
|
|
129
|
+
function buildRangeInput(ctl, cv, el) {
|
|
130
|
+
const input = el('input', 'ctl-slider-input');
|
|
131
|
+
input.id = ctl.id || '';
|
|
132
|
+
input.type = 'range';
|
|
133
|
+
if (cv) {
|
|
134
|
+
input.min = String(cv.posMin);
|
|
135
|
+
input.max = String(cv.posMax);
|
|
136
|
+
input.step = '1';
|
|
137
|
+
input.value = String(Math.round(posOf(ctl, ctl.defaultValue !== undefined ? ctl.defaultValue : SPEED_MID)));
|
|
138
|
+
} else {
|
|
139
|
+
input.min = String(ctl.min !== undefined ? ctl.min : 0);
|
|
140
|
+
input.max = String(ctl.max !== undefined ? ctl.max : 100);
|
|
141
|
+
input.step = String(ctl.step !== undefined ? ctl.step : 1);
|
|
142
|
+
input.value = String(ctl.defaultValue !== undefined ? ctl.defaultValue : input.min);
|
|
143
|
+
}
|
|
144
|
+
input.setAttribute('aria-label', ctl.label);
|
|
145
|
+
return input;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 渲染一个滑块控件(结构见文件头注释)。
|
|
150
|
+
* @param {object} ctl schema 控件声明(kind: 'slider')
|
|
151
|
+
* @param {object} ctx 面板上下文:{ isWired(id), onSetting(id, value) }
|
|
152
|
+
* @param {Function} el DOM 构造小工具(由 view.js 注入)
|
|
153
|
+
* @param {Function} markDisabled 置灰工具(由 view.js 注入)
|
|
154
|
+
* @returns {HTMLElement}
|
|
155
|
+
*/
|
|
156
|
+
export function renderSlider(ctl, ctx, el, markDisabled) {
|
|
157
|
+
const wired = ctx.isWired(ctl.id) && ctl.disabled !== true;
|
|
158
|
+
const cv = curveOf(ctl);
|
|
159
|
+
const box = el('div', 'ctl-slider');
|
|
160
|
+
box.dataset.ctl = ctl.id || '';
|
|
161
|
+
|
|
162
|
+
const head = el('div', 'ctl-slider-head');
|
|
163
|
+
head.append(el('span', 'ctl-slider-label', ctl.label));
|
|
164
|
+
// 契约 R2:首次渲染就用工厂里的当前值(有则覆盖 schema 默认),避免"先默认后跳"
|
|
165
|
+
const ivSlider = ctx && typeof ctx.initialValue === 'function' ? ctx.initialValue(ctl.id) : undefined;
|
|
166
|
+
const seed = ivSlider === undefined ? ctl.defaultValue : ivSlider;
|
|
167
|
+
const readout = el('span', 'ctl-slider-value', formatSliderValue(ctl, seed));
|
|
168
|
+
readout.dataset.readout = ctl.id || '';
|
|
169
|
+
readout.setAttribute('aria-live', 'polite');
|
|
170
|
+
head.append(readout);
|
|
171
|
+
|
|
172
|
+
const track = el('div', 'ctl-slider-track');
|
|
173
|
+
const input = buildRangeInput(ctl, cv, el);
|
|
174
|
+
// 滑块 input.value 存的是**格位**(非线性滑块要换算),所以按 posOf 定位而不是直接写数值
|
|
175
|
+
if (ivSlider !== undefined) {
|
|
176
|
+
try { input.value = String(Math.round(posOf(ctl, ivSlider))); } catch { /* 换算失败就用默认位置 */ }
|
|
177
|
+
}
|
|
178
|
+
track.append(input);
|
|
179
|
+
box.append(head, track);
|
|
180
|
+
|
|
181
|
+
if (!wired) {
|
|
182
|
+
markDisabled(input, ctl, ctx);
|
|
183
|
+
return box;
|
|
184
|
+
}
|
|
185
|
+
input.title = ctl.title || ctl.label;
|
|
186
|
+
readout.title = cv && cv.snap ? '双击回到基准值' : '双击复位到默认值';
|
|
187
|
+
|
|
188
|
+
/** 读当前格位 → 吸附 → 回写滑块 → 返回真实数值 */
|
|
189
|
+
const readSnapped = () => {
|
|
190
|
+
let pos = Number(input.value);
|
|
191
|
+
if (cv && cv.snap && Math.abs(pos - cv.snap.at) <= cv.snap.range) {
|
|
192
|
+
pos = cv.snap.at;
|
|
193
|
+
input.value = String(pos);
|
|
194
|
+
}
|
|
195
|
+
return valueOfPos(ctl, pos);
|
|
196
|
+
};
|
|
197
|
+
const commit = (v) => {
|
|
198
|
+
readout.textContent = formatSliderValue(ctl, v);
|
|
199
|
+
try { ctx.onSetting(ctl.id, v); } catch { /* 接线失败不影响面板自身显示 */ }
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
input.addEventListener('input', () => commit(readSnapped()));
|
|
203
|
+
// 双击读数复位:不额外占一行按钮
|
|
204
|
+
readout.addEventListener('dblclick', () => {
|
|
205
|
+
const back = ctl.defaultValue !== undefined ? ctl.defaultValue : valueOfPos(ctl, input.min);
|
|
206
|
+
input.value = String(Math.round(posOf(ctl, back)));
|
|
207
|
+
commit(back);
|
|
208
|
+
});
|
|
209
|
+
return box;
|
|
210
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/web/panel/toolbar-schema.js —— 顶栏按钮的**声明式表**(内置 5 个)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有这张表:顶栏原先在 index.html 里手写 5 个 <button>(每个带内联 SVG),
|
|
4
|
+
// 插件按钮就没有位置可插、更无法与内置按钮定序。改成"数据 → 渲染"之后,
|
|
5
|
+
// 内置与插件按钮走**同一个渲染器**、同一套排序与图标白名单。
|
|
6
|
+
//
|
|
7
|
+
// 字段口径:
|
|
8
|
+
// id DOM id(**必须与旧手写按钮一致**:context.js 在模块加载时按 id 取节点)
|
|
9
|
+
// icon 图标名(白名单见 icons.js 的三张表:TOPBAR_ICONS / GROUP_ICONS / ACTION_ICONS)
|
|
10
|
+
// label aria-label(可访问名)
|
|
11
|
+
// title tooltip(与 aria-label 可以不同:tooltip 更简短)
|
|
12
|
+
// action 事件名(由连接层 dispatch;渲染器不认识业务)
|
|
13
|
+
// order 升序;插件按钮缺省 1000,因此天然排在内置之后
|
|
14
|
+
// pressed 可选:'true'/'false' 初值的 aria-pressed(开关类按钮)
|
|
15
|
+
|
|
16
|
+
import { TOPBAR_ICONS, GROUP_ICONS, ACTION_ICONS } from './icons.js';
|
|
17
|
+
|
|
18
|
+
/** 内置按钮的有效顺序与旧 HTML 完全一致(左→右) */
|
|
19
|
+
export const TOOLBAR_ACTIONS = [
|
|
20
|
+
{
|
|
21
|
+
id: 'btn-library',
|
|
22
|
+
icon: 'panelLeft',
|
|
23
|
+
label: '模型库(显示/隐藏左栏)',
|
|
24
|
+
title: '模型库(显示/隐藏左栏)',
|
|
25
|
+
action: 'toggle-left-panel',
|
|
26
|
+
order: 10,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'btn-menu',
|
|
30
|
+
icon: 'panelRight',
|
|
31
|
+
label: '菜单(显示/隐藏右侧控制面板)',
|
|
32
|
+
title: '菜单(显示/隐藏右侧控制面板)',
|
|
33
|
+
action: 'toggle-right-panel',
|
|
34
|
+
order: 20,
|
|
35
|
+
pressed: 'true',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'btn-shot',
|
|
39
|
+
icon: 'camera',
|
|
40
|
+
label: '截图导出',
|
|
41
|
+
title: '截图导出(PNG,文件名带模型名与时间戳)',
|
|
42
|
+
action: 'screenshot',
|
|
43
|
+
order: 30,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'btn-cinema',
|
|
47
|
+
// 影院模式图标是"画框",与内置分组图标无关,单独登记在 TOOLBAR_EXTRA_ICONS
|
|
48
|
+
icon: 'cinema',
|
|
49
|
+
label: '影院模式(显示/隐藏顶栏与左右栏,Esc 或中心双击退出)',
|
|
50
|
+
title: '影院模式(Esc 或中心双击退出)',
|
|
51
|
+
action: 'toggle-cinema',
|
|
52
|
+
order: 40,
|
|
53
|
+
pressed: 'false',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'btn-fullscreen',
|
|
57
|
+
icon: 'fullscreen',
|
|
58
|
+
label: '全屏',
|
|
59
|
+
title: '全屏',
|
|
60
|
+
action: 'fullscreen',
|
|
61
|
+
order: 50,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 顶栏专用图标(内联 SVG 的 path/d 串):这三种在内置分组表里没有对应项。
|
|
67
|
+
* 从 index.html 原样搬过来,避免"迁移后图标变了"这种无意义回归。
|
|
68
|
+
*/
|
|
69
|
+
export const TOOLBAR_EXTRA_ICONS = {
|
|
70
|
+
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
|
|
71
|
+
cinema: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 4v16M17 4v16M2 12h20"/>',
|
|
72
|
+
fullscreen: '<path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/>',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 图标白名单:插件只能报**名字**,拿不到就退化成纯文本按钮。
|
|
77
|
+
*
|
|
78
|
+
* 为什么必须白名单:顶栏图标的实现是"注入 SVG 内层 HTML",
|
|
79
|
+
* 如果允许插件传任意串,等于允许它往宿主 DOM 里塞任意标记(声明式纪律的第一条禁止项)。
|
|
80
|
+
*/
|
|
81
|
+
export const TOOLBAR_ICON_WHITELIST = new Set([
|
|
82
|
+
...Object.keys(TOPBAR_ICONS),
|
|
83
|
+
...Object.keys(GROUP_ICONS),
|
|
84
|
+
...Object.keys(ACTION_ICONS),
|
|
85
|
+
...Object.keys(TOOLBAR_EXTRA_ICONS),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 按图标名取 SVG 内层内容。
|
|
90
|
+
* @param {string} name 图标名
|
|
91
|
+
* @returns {string|null} 命中白名单返回内层标记;否则 null(调用方退化成文本按钮)
|
|
92
|
+
*/
|
|
93
|
+
export function iconBodyOf(name) {
|
|
94
|
+
const key = String(name || '');
|
|
95
|
+
if (!key) return null;
|
|
96
|
+
if (TOOLBAR_EXTRA_ICONS[key]) return TOOLBAR_EXTRA_ICONS[key];
|
|
97
|
+
if (TOPBAR_ICONS[key]) return TOPBAR_ICONS[key];
|
|
98
|
+
if (GROUP_ICONS[key]) return GROUP_ICONS[key];
|
|
99
|
+
if (ACTION_ICONS[key]) return ACTION_ICONS[key];
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 插件按钮的**安全阀**(不是布局上限)。
|
|
105
|
+
*
|
|
106
|
+
* 历史:S3 时这里是 3,超出即丢弃 —— 用户既看不到按钮也不知道被跳过。
|
|
107
|
+
* 现在布局改成"插件按钮进横向滚动区",可见性问题由滚动解决;
|
|
108
|
+
* 保留 20 只是防"某个插件一口气注册上百个按钮"把 DOM 撑爆的安全阀。
|
|
109
|
+
* @type {number}
|
|
110
|
+
*/
|
|
111
|
+
export const PLUGIN_TOOLBAR_MAX = 20;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// src/web/panel/toolbar.js —— 顶栏渲染器(内置 + 插件按钮走同一套)
|
|
2
|
+
//
|
|
3
|
+
// 两类按钮的差异只有"事件怎么来":
|
|
4
|
+
// · 内置:DOM id 与旧手写按钮**完全一致**(btn-library / btn-menu / …),
|
|
5
|
+
// 所以既有连接层(topbar-actions.js 按 id getElementById 绑事件)**一行都不用改**。
|
|
6
|
+
// · 插件:点击走 onAction(与面板控件同一套回调通道),由装配层注入。
|
|
7
|
+
//
|
|
8
|
+
// 渲染器不认识业务、不发请求、不碰状态:只按数据造节点。
|
|
9
|
+
|
|
10
|
+
import { svgIcon } from './icons.js';
|
|
11
|
+
import { PLUGIN_TOOLBAR_MAX, TOOLBAR_ACTIONS, iconBodyOf } from './toolbar-schema.js';
|
|
12
|
+
|
|
13
|
+
// ⚠️ 这里**不能**顶层 import app/status.js(它 → app/context.js,会在模块加载时
|
|
14
|
+
// 用 getElementById 把顶栏按钮快照成 null)。顶栏渲染必须发生在 context 之前,
|
|
15
|
+
// 所以日志能力由装配层注入(opts.log),渲染器本身保持"零依赖 app 层"。
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 造一个顶栏按钮(内置与插件共用)。
|
|
19
|
+
* @param {{id:string, label:string, title?:string, icon?:string, pressed?:string, pluginId?:string}} item
|
|
20
|
+
* @returns {HTMLButtonElement}
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* 求出按钮的按下态。
|
|
24
|
+
*
|
|
25
|
+
* 支持两种写法(**getter 优先**,这也是本轮选定的形式):
|
|
26
|
+
* · `spec.pressed` 是函数 ⇒ 渲染时求值(可读 owner props)
|
|
27
|
+
* · `spec.pressed` 是布尔 ⇒ 直接当常量用
|
|
28
|
+
* 选 getter 的理由:与 owner props 的 getter 语义一致——插件不用自己记"什么时候该重绘",
|
|
29
|
+
* 宿主每次渲染/重建时重新求值即可;布尔常量仍可用,写起来最省事。
|
|
30
|
+
* @param {object} item 按钮描述
|
|
31
|
+
* @param {object} [props] owner props(只有当 pressed 是函数时才会传进去)
|
|
32
|
+
* @returns {boolean|undefined} undefined 表示"这个按钮没有按下态"
|
|
33
|
+
*/
|
|
34
|
+
function resolvePressed(item, props) {
|
|
35
|
+
const raw = item && item.pressed;
|
|
36
|
+
if (typeof raw === 'function') {
|
|
37
|
+
try { return raw(props || {}) === true; } catch { return undefined; }
|
|
38
|
+
}
|
|
39
|
+
if (raw === true || raw === 'true') return true;
|
|
40
|
+
if (raw === false || raw === 'false') return false;
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 写 aria-pressed(undefined ⇒ 移除属性,不冒充"关") */
|
|
45
|
+
function applyPressedAttr(btn, pressed) {
|
|
46
|
+
if (pressed === true || pressed === false) btn.setAttribute('aria-pressed', String(pressed));
|
|
47
|
+
else btn.removeAttribute('aria-pressed');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 插件按钮的横向滚动容器(幂等:已存在就复用)。
|
|
52
|
+
*
|
|
53
|
+
* 为什么单独一个容器:内置按钮必须**固定可见**、插件按钮可以很多 ——
|
|
54
|
+
* 把两者放进同一个 flex 容器时,插件一多会把内置按钮挤出屏幕。
|
|
55
|
+
* @param {HTMLElement} nav 顶栏 nav
|
|
56
|
+
* @returns {HTMLElement}
|
|
57
|
+
*/
|
|
58
|
+
function pluginStrip(nav) {
|
|
59
|
+
let strip = nav.querySelector(':scope > .header-plugin-actions');
|
|
60
|
+
if (!strip) {
|
|
61
|
+
strip = document.createElement('div');
|
|
62
|
+
strip.className = 'header-plugin-actions';
|
|
63
|
+
strip.dataset.pluginToolbar = '1';
|
|
64
|
+
nav.append(strip);
|
|
65
|
+
}
|
|
66
|
+
return strip;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildButton(item) {
|
|
70
|
+
const btn = document.createElement('button');
|
|
71
|
+
btn.type = 'button';
|
|
72
|
+
btn.className = 'icon-btn';
|
|
73
|
+
btn.id = String(item.id || '');
|
|
74
|
+
// 可访问名与 tooltip 分开:tooltip 可以更短(与旧 HTML 的口径一致)
|
|
75
|
+
btn.setAttribute('aria-label', String(item.label || item.id || ''));
|
|
76
|
+
btn.title = String(item.title || item.label || item.id || '');
|
|
77
|
+
// pressed 语义与内置按钮完全一致:只写 aria-pressed(样式由 .icon-btn[aria-pressed='true'] 负责)。
|
|
78
|
+
// undefined ⇒ 不写属性(纯动作按钮,如截图/全屏那种"按一下就走"的)。
|
|
79
|
+
applyPressedAttr(btn, item.pressed);
|
|
80
|
+
if (item.pluginId) {
|
|
81
|
+
btn.dataset.pluginId = String(item.pluginId);
|
|
82
|
+
btn.dataset.pluginToolbar = '1'; // 重建时按它清理,不影响内置按钮节点
|
|
83
|
+
}
|
|
84
|
+
const body = iconBodyOf(item.icon);
|
|
85
|
+
if (body) {
|
|
86
|
+
btn.append(svgIcon(body));
|
|
87
|
+
} else {
|
|
88
|
+
// 图标名不在白名单(或没给):只显示文本 label —— 绝不放行任意标记
|
|
89
|
+
const text = document.createElement('span');
|
|
90
|
+
text.className = 'icon-btn-text';
|
|
91
|
+
text.textContent = String(item.label || item.id || '');
|
|
92
|
+
btn.append(text);
|
|
93
|
+
}
|
|
94
|
+
return btn;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 渲染内置按钮(幂等:已渲染过就直接返回)。
|
|
99
|
+
*
|
|
100
|
+
* 必须在 main.js 之前跑:`context.js` 在模块加载时用 getElementById 缓存这些节点。
|
|
101
|
+
* @param {HTMLElement} nav 顶栏 `<nav class="header-actions">`
|
|
102
|
+
* @returns {number} 本次新建的按钮数
|
|
103
|
+
*/
|
|
104
|
+
export function mountToolbar(nav) {
|
|
105
|
+
if (!nav) return 0;
|
|
106
|
+
if (nav.dataset.toolbarMounted === '1') return 0;
|
|
107
|
+
const items = [...TOOLBAR_ACTIONS].sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
108
|
+
for (const item of items) nav.append(buildButton(item));
|
|
109
|
+
nav.dataset.toolbarMounted = '1';
|
|
110
|
+
return items.length;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 同步插件按钮(每次面板重建后调用)。
|
|
115
|
+
*
|
|
116
|
+
* 规则(设计 §7.2 的声明式纪律):
|
|
117
|
+
* · 只追加在内置按钮之后,按 order 升序(缺省 1000)
|
|
118
|
+
* · 图标只认白名单名字,拿不到就只显示文本
|
|
119
|
+
* · 超过 PLUGIN_TOOLBAR_MAX 的部分不渲染,并记一条 info(溢出菜单留后续)
|
|
120
|
+
* @param {HTMLElement} nav 顶栏 nav
|
|
121
|
+
* @param {Array} entries 插槽登记项(name === 'toolbar.actions')
|
|
122
|
+
* @param {{onAction?: (entry:object, ev:MouseEvent) => void}} [opts]
|
|
123
|
+
* @returns {{added:number, skipped:number}}
|
|
124
|
+
*/
|
|
125
|
+
/**
|
|
126
|
+
* 单值插槽 `panel.brand`:换掉顶栏品牌名(**纯文本**,不做标记解析)。
|
|
127
|
+
*
|
|
128
|
+
* 生效模型:"后注册者胜出"——按全局注册序号 seq 取最大的那条;
|
|
129
|
+
* 它的 disposer 执行后自然回退到**上一个**注册者(不是直接回内置),因为记录被移除后
|
|
130
|
+
* 重新算一遍"最大 seq"就落到前一条。一条都没有时用内置文本。
|
|
131
|
+
* @param {HTMLElement} el 品牌元素(#brand-title)
|
|
132
|
+
* @param {Array} entries 插槽登记项
|
|
133
|
+
* @param {{fallback?: string}} [opts]
|
|
134
|
+
* @returns {{text: string, pluginId: string|null}}
|
|
135
|
+
*/
|
|
136
|
+
export function syncBrand(el, entries, opts = {}) {
|
|
137
|
+
const fallback = String(opts.fallback || 'ModelStage');
|
|
138
|
+
if (!el) return { text: fallback, pluginId: null };
|
|
139
|
+
const list = (Array.isArray(entries) ? entries : [])
|
|
140
|
+
.filter((e) => e && String(e.name || '') === 'panel.brand')
|
|
141
|
+
.sort((a, b) => (Number(a.seq) || 0) - (Number(b.seq) || 0));
|
|
142
|
+
const top = list.length ? list[list.length - 1] : null;
|
|
143
|
+
const spec = (top && top.spec) || {};
|
|
144
|
+
// 文本取 spec.text / spec.label;用 textContent 写入(HTML 字符串会被当字面文本,不会解析)
|
|
145
|
+
const text = top ? String(spec.text != null ? spec.text : (spec.label != null ? spec.label : '')) : fallback;
|
|
146
|
+
el.textContent = text || fallback;
|
|
147
|
+
if (top) el.dataset.brandPlugin = String(top.pluginId || '');
|
|
148
|
+
else delete el.dataset.brandPlugin;
|
|
149
|
+
return { text: el.textContent, pluginId: top ? String(top.pluginId || '') : null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function syncPluginToolbar(nav, entries, opts = {}) {
|
|
153
|
+
const log = typeof opts.log === 'function' ? opts.log : () => {};
|
|
154
|
+
if (!nav) return { added: 0, skipped: 0 };
|
|
155
|
+
// 插件按钮统一进滚动容器;容器整体清空重来(内置按钮不在里面,不会被误伤)
|
|
156
|
+
const strip = pluginStrip(nav);
|
|
157
|
+
strip.textContent = '';
|
|
158
|
+
const list = (Array.isArray(entries) ? entries : [])
|
|
159
|
+
.filter((e) => e && String(e.name || '') === 'toolbar.actions')
|
|
160
|
+
.sort((a, b) => (Number(a.order) || 1000) - (Number(b.order) || 1000));
|
|
161
|
+
let added = 0;
|
|
162
|
+
let skipped = 0;
|
|
163
|
+
for (const entry of list) {
|
|
164
|
+
if (added >= PLUGIN_TOOLBAR_MAX) {
|
|
165
|
+
skipped += 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const spec = (entry && entry.spec) || {};
|
|
169
|
+
const props = typeof opts.buildProps === 'function' ? opts.buildProps(entry) : {};
|
|
170
|
+
const btn = buildButton({
|
|
171
|
+
id: String(entry.id || `plugin-toolbar-${added}`),
|
|
172
|
+
label: String(spec.label || entry.label || entry.id || ''),
|
|
173
|
+
title: String(spec.title || spec.label || entry.label || entry.id || ''),
|
|
174
|
+
icon: String(spec.icon || ''),
|
|
175
|
+
pluginId: entry.pluginId,
|
|
176
|
+
pressed: resolvePressed(spec, props),
|
|
177
|
+
});
|
|
178
|
+
btn.addEventListener('click', (ev) => {
|
|
179
|
+
try {
|
|
180
|
+
if (typeof opts.onAction === 'function') opts.onAction(entry, ev);
|
|
181
|
+
} catch { /* 插件回调抛错不影响顶栏 */ }
|
|
182
|
+
// 点完重新求值一次按下态:插件不必自己记"该重绘了"(getter 形式的直接受益点)。
|
|
183
|
+
// 只重算这一个按钮:整条重建会丢焦点(键盘用户会掉 focus)。
|
|
184
|
+
try { applyPressedAttr(btn, resolvePressed((entry && entry.spec) || {}, props)); } catch { /* 忽略 */ }
|
|
185
|
+
});
|
|
186
|
+
strip.append(btn);
|
|
187
|
+
added += 1;
|
|
188
|
+
}
|
|
189
|
+
if (skipped) {
|
|
190
|
+
// 正常情况下永远不会触发(20 是防失控的安全阀,不是布局上限);真触发说明有插件在刷按钮
|
|
191
|
+
log('warn', `顶栏插件按钮超过安全阀(${PLUGIN_TOOLBAR_MAX} 个),已跳过 ${skipped} 个:${list
|
|
192
|
+
.slice(PLUGIN_TOOLBAR_MAX).map((e) => e.id).join(' / ')}`, { source: 'plugin-toolbar' });
|
|
193
|
+
}
|
|
194
|
+
return { added, skipped };
|
|
195
|
+
}
|