Skip to content

历史的游标

背景:动作参数调整没有撤销——手滑调坏一个滑块,只能凭记忆调回去;模型删除后历史栈残留幽灵条目。 过程:per-model 快照历史栈(push/undo/redo/jumpToHistory)+ cursor 游标 + clearHistory 接入模型删除链路。


桌面壳在调一个动作的参数。

它把"抬脚高度"从 0.3 拉到 0.8——脚抬起来了,动作变得夸张。再拉回 0.3,不行,拉过头了,变成 0.2——脚几乎贴着地面蹭。

"我要 0.35。" 它说,"但我现在不知道刚才的 0.3 长什么样了。"

它盯着滑块。0.2、0.3、0.4……数值是死的,感觉是活的。它刚才见过 0.3 的效果,但它没法把时间倒回去再看一眼。

"参数没有记忆。" 桌面壳说,"我拧一个旋钮,拧坏了,只能瞎拧回去。这个世界的所有滑块都只活在'现在'——没有'刚才'。"

"你想要什么?" 外交官问。

"我想要一个旋钮的回车键。" 桌面壳说,"调坏了,能退一步。再退一步。退到我说'就是它'的那个瞬间。"


一、快照而不是操作

桌面壳写了 pushHistory

但它没有记录"你刚才把滑块从 0.3 拉到了 0.8"——没有记录操作,而是记录状态:

typescript
export function pushHistory(
    modelId: string,
    description: string,
    snapshotBuilder: () => Record<string, { enabled: boolean; params: Record<string, ParamValue> }>
): void {
    // 用 snapshotBuilder 回调生成快照——不直接读 registry,
    // 由 module-base 闭包捕获,避免循环依赖
    const snapshot = snapshotBuilder();
    ...
}

"记录操作是脆弱的。" 桌面壳说,"'滑块从 0.3 到 0.8'——这条记录只在'滑块还是那个滑块'时有用。记录快照是健壮的:把这一刻所有参数的值拍下来,恢复时直接整体铺回去。"

"为什么用回调而不是直接读?"

"因为 history 模块不能依赖 registry——那是循环依赖。让调用方把'如何读当前状态'作为函数传进来,history 只负责存和取。我不知道你的状态长什么样,你告诉我怎么读,我帮你记住。"

一条历史记录长这样:

typescript
interface MotionHistoryEntry {
    timestamp: number;   // 什么时候拍的
    snapshot: Record<string, { enabled: boolean; params: ... }>;  // 那一刻的世界
    description: string; // 为什么拍("修改抬脚高度")
}

"每一条记录,都是一张动作参数的底片。"


二、游标,而不是栈

实现撤销/重做时,桌面壳做了一个关键决定:不用"弹出/压入"的栈,用"游标"。

typescript
export function undo(modelId: string, applySnapshot: SnapshotApplier): boolean {
    // cursor 左移 → 应用 cursor 处快照
}
export function redo(modelId: string, applySnapshot: SnapshotApplier): boolean {
    // cursor 右移 → 应用 cursor 处快照
}

"栈只有顶和底。你压进去五个状态,撤销一个,想'重做'就得把它重新压回去——栈的 undo 会摧毁 redo 的可能。"

"游标呢?"

"游标在一条历史线上来回走。撤销是往左走,重做是往右走——你退了三步,只要没走新的路,就还能原路返回。只有当你退回去又改了新参数,游标右边的未来才被截断。"

桌面壳用一根手指在桌面上划了一条线:

"历史不是一摞盘子,是一条轨道。你站在轨道上,往前是未来,往后是过去。改动是铺轨,撤销是倒车——倒车的时候,轨道还在。"


三、上限五十

但轨道不能无限长。

typescript
const MAX_HISTORY = 50;

"为什么是五十?"

"每一条快照,都是一整份参数状态。拖十次滑块、拧五个旋钮、换三个预设——五十条快照能把一个模型的历史装得满满当当。再长,内存就吃不住了。"

"五十条够用吗?"

"够。" 桌面壳说,"你调一个动作,反悔超过五十次的概率,比模型自己走丢还低。上限不是限制,是保护——历史越长,越没人会往回翻那么远。五十条,够你追悔,不够你沉溺。"

它还给历史加了一个 jumpToHistory——可以直接跳到任意一条记录:

typescript
export function jumpToHistory(
    modelId: string,
    entryId: number,
    applySnapshot: SnapshotApplier
): boolean {
    // cursor 直接指向第 entryId 条 → 应用该条快照
}

"撤销是走一步,跳转是飞过去。" 桌面壳说,"历史面板上列出五十条记录,你点哪条,就回到哪条——像翻书签,而不是一页页翻。"


四、幽灵的清理

一切正常之后,桌面壳发现了一个漏洞。

它删掉了一个模型。然后调另一个模型的参数——历史面板里,那个已删除模型的历史记录还在。

"这是一条幽灵历史。" 它说,"模型已经没了,但它的五十条快照还躺在历史栈里。占着内存,还误导人——用户看着'还有历史可撤销',按下去,恢复一个不存在的模型?"

"模型删除的时候,该做什么?"

"清场。"

桌面壳把 clearHistory 接进了模型删除的链路:

typescript
// model-manager onRemoveModel → clearHistory(id)
export function clearHistory(modelId: string): void {
    // 清空该模型的 entries 与 cursor
}

"模型被删的那一刻,它的历史一并烧掉。没有幽灵记录,没有假的可撤销提示——'撤销'按钮诚实地说:这个模型已经没有过去了。"

"但这样,删掉的模型就真的救不回来了?"

"救不回来。" 桌面壳说,"这是设计上的取舍:历史属于活着的模型。模型活着,你有五十次反悔的机会;模型死了,它的过去随它而去。清空不是残忍,是诚实——别给一个不存在的世界留后悔药。"


尾声

桌面壳调好了那个动作。

抬脚高度 0.35。它看着滑块,忽然不再害怕拧坏了——它知道 0.3 长什么样,0.4 长什么样,每一条都躺在历史轨道上等着它回头。

"历史是一条轨道," 它说,"你站在上面,能往回走,能往前走。唯一让它失效的,是你走出新的岔路——那时旧的未来才被截断。"

"那模型删除呢?"

"那是轨道被拆了。" 桌面壳说,"模型都没了,轨枕、铁轨、站牌,一并收走。历史不是执念,它是活人的工具——死人不需要后悔药。"

它又调了一个参数。轨道上多了一块枕木。

"五十条," 它轻声说,"够我追悔,不够我沉溺。这就是历史最健康的长度。"


教训:撤销不是栈的弹出一操作记录会互相推翻,快照才是;游标比栈诚实——倒车不毁轨道。

历史属于活着的模型:删除即清场,别留幽灵。