Appearance
议院改革
背景:五个弹窗各有独立导航逻辑,MenuStack 无法统一管理多层浮层。
过程:MenuStack 多层浮层重构 + 场景面板移除 + 菜单架构统一。
议会大厅最近越来越挤了。
五个弹窗挤在同一层楼——模型库、动作库、设置、下载、外部库——每个弹窗有自己的一套导航逻辑。有的用 popupStack,有的用 motionPopupStack,有的干脆用静态 HTML。
"你到底是议会还是菜市场?" 模型库 抱怨道,它的标题栏右边挂了四个按钮:关闭、外部库、重新扫描、搜索,再加面包屑导航,一行塞了七个东西。
MenuStack —— 被推举为议长的那个类 —— 看着自己的代码叹了口气。它内部维护着一个 levels: PopupLevel[] 栈,每次导航就把旧列表滑出去、新列表滑进来。这个模式其实没错。但问题出在实现:五个弹窗里有三套不同的 renderPopup 实现,加起来两百多行几乎一样的 DOM 构建代码。
它下了一个决心:所有弹窗必须用同一套 render 引擎,否则不加新功能。
它不是在修 bug——bug 是单个的、孤立的。它面对的是一种病:臃肿。五套弹窗、三套 render、一百多行重复代码——这不是哪里坏了,是整个身体在发炎。治炎症不能头痛医头,得找到病灶。
改革的第一个大刀砍向 extraButtons。MenuStack 皱了皱眉——这是它找到的第一个病灶。
旧议会里有个机制:每个弹窗可以把 DOM 元素(比如关闭按钮)作为 extraButtons 传进来。问题是 appendChild 是移动操作——第一次 push 时按钮从原位置被移走,第二次 push 时它已经不见了。用户在第二层弹窗里找不到关闭按钮,只能砸浏览器。
"这是把唯一的门拆了搬进去," btnClosePopup 抱怨道,"第一次还能回来,第二次我就被砌进墙里了。"
解决方案是 extraButtonFactory:每次创建新层时,工厂函数根据模板克隆一个新按钮,重新绑定事件。不再是拆门搬门,而是按图纸每天造一扇一模一样的新门。
typescript
extraButtonFactory: () => {
const closeBtn = dom.btnClosePopup.cloneNode(true) as HTMLButtonElement;
closeBtn.addEventListener("click", hidePopup);
return [closeBtn];
};btnClosePopup 第一次摸到了自己的工作台图纸。它笑了。
第一刀切下去,MenuStack 看到了脓。extraButtons 不是唯一的病灶——整个布局都在发炎。病灶找到了。接下来是动手术。
第二刀是容器限高。MenuStack 深吸一口气——这刀要动地板。
旧议会大厅是固定高度的——height: 80vh。四个按钮时它八层楼高,二十个按钮时它也八层楼高。用户说:"我就四个选项,你给我看半栋楼的水泥墙?"
但要让容器自适应内容,就必须放弃 position: absolute; inset: 0 的层层叠加方案。每一层不能再是绝对定位在容器里的浮层,而必须是 position: relative 的正常流元素。
议会大厅的地板从绝对层变成了相对层。
之前(一个容器,内容滑动替换):
┌─ 议会大厅 (80vh) ─────────────┐
│ 模型 > 相机模式 │
│ ┌─────────────────────────┐ │
│ │ ○ 轨道 │ │
│ │ ✈️ 自由飞行 │ │
│ │ ○ 镜头预设 │ │ ← 滑入/滑出
│ │ ○ 演唱会 │ │
│ └─────────────────────────┘ │
└────────────────────────────────┘之后(各层独立浮层,层叠卡片效果):
┌─ 第二层 ─────────┐
│ ← 相机模式 │ ← 新浮层淡入,略偏移
│ ○ 轨道 │
┌─ 第一层 │ ✈️ 自由飞行 │
│ 模型 │ 稍下层半透明 │
│ 加载模型│ │
│ 相机模式│ │
└─────────┴──────────────────┘但绝对定位的层无法让容器自适应内容。要自适应,层必须回到正常流。
"那就回到正常流," MenuStack 说,"但保留卡片叠加的视觉效果。"
解决办法是切换:push 时旧层 display: none,新层 display: block。每次只看到一个层,容器自适应它的内容。四按钮时四层楼高,二十按钮时二十层楼高,最高不超过 80vh。
容器限高解决的是「高度」的问题。但「位置」的问题还在。
第三刀是按钮搬家。
"我每天在这个标题栏里从左边跑到右边," btnManageExternal 气喘吁吁地说,"🔌 外部库、🔄 重新扫描、✕ 关闭——全挤在一行。用户的拇指够不着右边的按钮。"
"那你们想去哪里?" MenuStack 问。
"我想去设置页," 🔌 说,"我和下载弹窗的场合差不多,都是用来看别处的资源的。"
"我想去根菜单," 🔄 说,"我一般在加载模型前使用,放首层最顺手。"
于是 🔌 搬到了设置弹窗的根菜单,与显示、下载、系统平级;🔄 搬到了模型弹窗的根菜单,作为最后一行 action。标题栏只剩 ✕ 关闭。MenuStack 看着搬完家的按钮,满意地点了点头。
同时,右侧的 场景面板——一个一直挂在右边的小窗口,显示已加载模型列表——也被移除了。场景面板挂在右边,像一扇没人走的侧门。用户从来不看右边——他们的视线从左到右,从上到下。侧门不在视线上,就不存在。
它的功能被内联到模型弹窗的根菜单顶部:已加载的模型作为按钮,上面一条分隔线。
🎭 泠鸢yousa-登门喜鹊(红披风... ← 已加载模型
🎭 少女~[vup][沐可milco]...
────────────────────────────── ← 分隔线
📁 加载模型 ← 以下为常规模块
📷 相机模式
⏱ 动作倍率
✨ 角色定制
🔄 重新扫描divider 成了 PopupRow.kind 的第四个值——"folder" | "model" | "action" | "divider"。它在 DOM 里就是一条 1px 灰线,不响应点击,单纯地把动态区域和静态菜单分开。
第四刀是命名。
refreshThumbnails 静静地躺在 menu.ts 里,一百多行代码,从未被调用过。它的 dataset.pmxPath 从未被设置,它依赖的缩略图在改成 divider + 场景内联模式后就不走这个路径了。
reRender 里声明了 labels 和 dirs 变量,但从未使用——可能是某次重构后忘了删。
"死代码就是议会里的空椅子," MenuStack 说,"占位、积灰、让下一个走进来的人困惑。"
删掉。MenuStack 烦躁地删掉。两处共 22 行。
死代码清完了。但活着的代码也有问题——它们显示的名字不对。
第五刀是文件名即身份。
一直以来,模型在弹窗里默认显示 name_jp——PMX 头文件里的日文名。但这个日文名经常是"只允许改造,优化骨骼和刚体,重制UV =======",或者干脆是连续二十个半角片假名。
"玩家想改名字,文件名就是最好更改的啊。"用户说。
一语惊醒。默认优先级从 name_jp 改为 filename。文件名叫什么,弹窗就显示什么。想改名?在文件管理器里改就好——这比在设置里翻三层菜单快多了。
同时,Go 端的 isGarbageModelName 开始过滤 name_en。以前只过滤 name_jp,导致被 name_jp 筛掉的垃圾文本从 name_en 侧门溜进来继续展示。现在前后门都关了。
名字终于叫对了。MenuStack 笑了。
"你还记得我刚才说的吗?" 面包屑问 MenuStack。"用户看不懂 模型 > 相机模式 > 镜头预设 这种累加路径。"
"那你想怎么样?"
"我只想看到 当前这一层 叫什么。然后给我一个返回箭头。"
← 镜头预设
"可以," MenuStack 开始改 createLayer。"根层不显示箭头,子层显示蓝色 ←,点击调用 this.pop()。简简单单。"
用户看了之后说:"这个才是触屏友好的。"
面包屑是 MenuStack 给自己留的备忘录——每走一层,记一层。不是给用户看的路径,是给用户看的「你在哪」。
大改完成。menu.ts 代码量控制住了,library.ts 清掉了三套重复的 render 实现,场景面板从独立的 HTML 变成了 MenuStack 的一个 divider 行。
五刀砍完,MenuStack 站在大厅中央环顾四周。空椅子没了,门不再被拆了搬来搬去,按钮各就各位,名字终于叫得对了。
它想起自己刚当议长时的心急——恨不得一口气把所有功能都塞进来。现在它明白了:议会的职责不是装东西,是让该在的位置上的东西被看见。
它在自己的数据表上加了一行记录:
教训:按钮和面板的位置不是 UI 问题,是信息层级问题——场景按钮放在根菜单顶部比挂在右侧面板节省一次眼球搜索。