View Memory

by zhechen
5
4
3
2
1
Score: 50/100

Description

记住文件列表展开状态、每个 PDF 的阅读位置、每个画布/绘图的视窗,重启后回到原处。桌面端专用。

Reviews

No reviews yet.

Stats

0
stars
43
downloads
0
forks
13
days
5
days
5
days
0
total PRs
0
open PRs
0
closed PRs
0
merged PRs
0
total issues
0
open issues
0
closed issues
15
commits

Latest Version

5 days ago

Changelog

这次日志(你开着 debug)直接指认了问题,没靠猜。

日志里的证据

记录位置:…数学物理方法….pdf 第 226 页 → 第 226 页(差异:scrollTop: 638→635)
记录位置:…第 226 页 → 第 226 页(差异:scrollTop: 639→573)      ← 一秒后又是它
PDF 阅读位置表已对平:6 条                                        ← 一分钟刷几十行

页码从头到尾没变,变的是 scrollTop —— 那是 pdf.js 在渲染过程中自己微调滚动位置。而我们把这个抖动判成「表变了」,于是每秒把整张 pdfjs.history 写一遍。

这一点值得单独说:localStorage 是同步 IO,占的是主线程;而 PDF 的 canvas 绘制也在主线程。 你截图里那个「页码显示 28/93、画面却全黑」,与这种持续阻塞是吻合的。

改了三处

① 我们不再写 pdfjs.history(除了打开前那一次)。 pdf.js 每次滚动本来就会把整张表写回去,而它的 database 就是我们交给它的共享表 —— 它写的时候天然带上全部条目。我们那次写是纯重复劳动。现在只保留「打开 PDF 之前写一次」,让 pdf.js 自己读到位置去恢复;其余时间只做一件事:让所有打开的视图都指向同一张表(这才是「多开 PDF 位置互相覆盖」的真正解法)。

② 补拍定时器加了硬上限与去重。 上限 10 个,超了直接丢弃并记账;id 存进 Set,执行时自己删掉。 之前那段是「数组超过 64 就截短到 32」—— 但被截掉的 id 没有 clearTimeout,那些定时器照样执行。所以那个上限是个假象。

③ layout-change 加 400ms 节流。 切视图、视图重建都会连着触发它,而它一次就排 6 个补拍。

顺便让限制自己会说话

被丢弃的补拍数、被节流的 layout 次数、以及「一秒内跑了太多次补拍」,各会写一行日志。下次你再反馈,我直接读这几行,不用再推理。

说清楚边界

这些都没有被证明是 4GB 的原因。 我能做的是把我们这一侧的浪费和失控都收干净 —— 每秒一次同步写盘、定时器无限增长,这两条都是实打实的(都有日志)。至于到底是 app 还是插件,仍然需要那次对照实验:

把「View Memory」禁用 → 重启 Obsidian → 快速打开那两个 PDF、来回切几次

  • 还崩 / 还涨 → 是 Obsidian + pdf.js 自己的事(两个大部头 PDF 双开本身就是重活)
  • 不崩了 → 是我们的锅,这轮加的日志会指出是哪一段

回归

合并套件 44 → 54 项,视窗侧 199 项,全绿。 其中最关键的一条:让一个 PDF 视图的 scrollTop 连抖十二拍,断言 localStorage 一次都没被写 —— 先验过红(把「每拍写一次」塞回去,它立刻报「写了 12 次」)。

README file from

Github

View Memory(view-memory)

需要 Obsidian 1.13.0 或更高:设置页用的是 1.13 起的声明式设置接口,这样这些设置项才会进 Obsidian 自己的设置搜索。

替 Obsidian 记住两类它自己记不住、或者会记错的东西:

管什么 为什么需要这个插件
文件列表 左侧文件树展开到哪几层 Obsidian 存在 localStorage 里其实是存对了的,坏在恢复环节:社区插件多、启动慢时,一启动整棵树就被收起
阅读位置与视窗 每个 PDF 读到第几页、每个笔记滚到哪、每个 Excalidraw 画布停在哪(缩放/偏移) PDF 的位置存在 pdf.js 自己那张表里,多个 PDF 同时打开时会互相覆盖;Markdown 的滚动位置 Obsidian 不保证恢复;Excalidraw 的视窗压根不往文件里存,且它的「打开时缩放以适应」默认开着

两件事本是同构的:都是「宿主把状态存在别处 / 存得不靠谱 → 我们自己按 vault 存一份并在合适时机套回去」。合并成一个插件后,轮询定时器、用户输入监听、设置页样板都只要一份。

由 fold-memory 与 view-memory 两个插件合并而来(2026-09-19)。第一次启用时会把这两个旧插件 data.json 里的记录搬过来一次。


一、文件列表展开状态(折叠)

真相

  • 展开状态不在 workspace.json(文件列表视图只序列化 sortOrder / autoReveal / showSearch / searchQuery)。
  • 它在 localStorage,键 file-explorer-unfold(宿主会自动加上 vault id 前缀),值是一个数组,内容是当前展开着的文件夹路径 —— 默认是折叠的,所以存的是"例外集合"。
  • saveFolds() 节流 200ms,且 if (!workspace.layoutReady) return;loadFolds() 只在 onLayoutReady 里跑一次。

写侧实测是正常的(从 leveldb 里挖出过多条历史记录、路径全有效),所以问题在恢复。插件的策略是不等它:自己按 vault 存快照,启动后等 fileItems 真正建好再重新展开,之后每秒对账。顺手调宿主的 requestSaveFolds() 让它自己那份存储也刷新一遍。

三道防线(最关键的部分)

这类"替宿主补做"的插件,最大的风险不是没修好,而是把好快照洗掉 —— 那比不修还糟:

  1. armed 之前一个字都不写盘。 恢复完成前观察到的任何状态都不可信(此刻界面可能正处于"丢失"的样子)。
  2. fileItems 为空 = 状态未知 → 跳过。 不能把"条目还没建好"当成"用户把整棵树收起来了",否则真正的状态出现后反而永远修不回来。实现上 currentSig() 返回 string | null,null 表示未知。
  3. 启动宽限期(5s)内任何缩水都判为"宿主丢了折叠" → 恢复而不是保存;过宽限期后只拦"缩到 0"和"≥60% 且 ≥3 项"。用户刚点过/敲过(2s 内)则一律认。

二、阅读位置与视窗

PDF:多开时互相覆盖

  • 位置在 localStorage["pdfjs.history"],按 PDF 内容指纹索引,形如 {files:[{fingerprint,page,zoom,scrollLeft,scrollTop,rotation,sidebarView,...}]}。
  • pdf.js 的 ViewHistory 构造时把整张表读成实例快照,之后每滚动一次就把整份快照写回(setMultiple() → _writeToStorage(),没有任何防抖),表还有 20 条上限。
  • 于是每个 PDF 视图各握着一份自己的旧快照 —— 谁后写,谁把别人的条目整条抹掉或倒回旧值。你最后停在 pdf1,pdf1 赢;pdf2 就回到很久以前的记录。

取证:.workbuddy/scripts/_pdf_history.cjs 从 leveldb 把那张表挖出来,实测看到过同一张表 6 条 → 1 条 → 6 条 反复被覆盖。

修法四层:

  1. 每秒把「磁盘上的表 + 我们按路径存的记录 + 打开视图的实况」合成一张(优先级 磁盘 < 记录 < 实况),并让所有打开的 PDF 视图共用同一个 database 对象 —— 它们写的是同一份,互相覆盖从根上没了。
  2. 监听 file-open,在 pdf.js 读表之前把该文件记录写回 localStorage → 它自己的恢复链路(连页内滚动位置一起)就正常工作了,不用我们去算滚动坐标。
  3. 兜底 setEphemeralState({ subpath: "#page=N" }) 深链跳页。
  4. 另按文件路径存一份记录(免疫"内容指纹变化"和"20 条上限淘汰")。

内部路径(从 obsidian-1.13.7.asar 读出来的): leaf.view.viewer.child.pdfViewer = PDFViewerApplication → .store(ViewHistory)/ .pdfViewer(pdf.js 的 PDFViewer)。

Markdown:滚到哪

  • 位置用 MarkdownView.currentMode.getScroll() 取,返回的不是像素,而是小数行号 —— 本机 1.13.7 的实现是 行号 + 行内偏移 / 行高。
  • 回填必须走 leaf.view.setEphemeralState({ scroll }),不要直接调 currentMode.applyScroll(): 预览模式的 renderer.applyScroll() 在「文本还没渲染完 / 段落还没测量」时会安静地 return false,而刚打开文件恰好就是这个状态 → 直接调它经常等于没调(表现是"完全不动、也不报错")。 走 setEphemeralState 时内部会转成 applyScrollDelayed:先试一次,不成就在 onRendered 之后再来一次。源码模式同样认 scroll 这个字段,还会把值记进 leaf 状态。
  • 这个口径源码模式与预览模式通用:Obsidian 自己切换模式时,就是拿一边的 getScroll() 喂给另一边的 applyScroll(),所以不必额外记"存的时候是哪个模式"。
  • 存取时机:file-open 之后补几拍(250 / 600 / 1300 / 2600 ms)—— Markdown 没有"自己的存储" 可以先写进去,只能等它渲染完再把位置摆回去。

Excalidraw

md 内嵌的绘图数据里没有 scrollX / scrollY / zoom / appState,而插件默认 zoomToFitOnOpen = true → 每次打开必然缩到全图。插件记住 scrollX/scrollY/zoom,回填时先 preventAutozoom()(1500ms 守卫)再 updateScene。

核心 Canvas

默认不接管:它的 getState() 返回 {x,y,zoom} 且被包进 leaf state,本来就写进了 workspace.json。如果发现它也丢,设置里把开关打开即可。

五道防线

  1. 视图未就绪(ready=false)跳过。

  2. 套用后 2.5s 静默期:期内不回读、不重复套 —— 否则我们刚写的值立刻被当成"用户的新状态"读回来,把记录冲成中间态。("已经到位"这个记账不受静默期影响,否则它要等 2.5 秒才被认下来。)

  3. ★ 判断「用户在动」只认真实输入事件(wheel / touchstart / touchmove / pointerdown / keydown),而且必须落在这个视图自己的容器里(按「叶子 + 路径」记账,点侧边栏不算)。 绝不能拿「实况变了」来代替 —— 宿主自己也会异步落位:md 渲染完成后会自己把滚动位置摆一下、pdf.js 会自己去读那张表。把它们当成"用户已经接手",就会永久交还、恢复一次都不跑(v1.2.3 的「完全没动」就是这么来的,日志里连一条「套用」都没有)。 所以 click 与 scroll 也不能收:它们同样会被宿主的程序化滚动触发。 另外,我们「看见」视图的时刻 ≠ 视图出现的时刻:tick 一秒一拍,用户完全可能在视图刚出现时就滚了,而那一下发生在我们第一次看见它之前。这种输入照样算数(给了 1.5s 的观察余量),否则就会出现「切到 PDF 滚一下,零点几秒后被拽回原页」。 用户一被认成"接手",该视图就永久交还(handsOff),之后只记录。

  4. ★ 一个视图只摆有限次(这条是「PDF 来回闪」的根因):命中任一即永久交还 ——

    • 用户真的操作过它(见第 3 条);
    • 已经和记录到位了;
    • 试满 maxAttempts。

    少了这条,"摆回记录位置"会变成长期悬在头上的纠正:用户从第 15 页翻到 16 页,过一会儿被拽回 15,再跳回 16,来回闪。

  5. 恢复阶段有时间上限(restoreWindowMs,默认 6s,从视图第一次就绪算起):过了就只记录、不再套用。

  6. 同一文件连续套用 6 次仍不成功就停手:防的是"视图被反复重建 → attempts 被重置 → 我们跟着反复套用"那种失控(画面会反复跳,还可能把宿主拖垮)。成功到位就把计数清零,所以正常地反复开关同一份文件不受影响。

  7. 页码超出文档范围就不跳:记录里的页码大于这篇 PDF 的总页数时(文件换过、记录过期)只写日志、不动手 —— 硬跳只会落在最后一页,然后每一拍都判定"不符"再跳一次。

不动手也别乱写盘

  • 我们几乎不写 pdfjs.history —— 只在打开某个 PDF 之前写一次(preparePdfFor),让 pdf.js 自己读到位置去恢复。之后整张表交给 pdf.js 维护:它滚动时本来就会把整张表写回去,而它的 database 就是我们给它的共享表,所以写的时候天然带上全部条目。
  • 为什么不写:pdf.js 渲染时会反复微调 scrollTop(日志里 573 / 635 / 638 / 639 那样来回),每次都判成"表变了",于是变成每秒一次同步写盘。localStorage 是同步 IO,占的是主线程 —— 而 PDF 的 canvas 绘制也在主线程。1.2.6 的日志里「表已对平」一分钟刷几十行就是这个。
  • PDF 浮点字段比对给 2px 容差(diffPdfValues),记录侧不会因末位抖动而每秒重写。
  • 那张表对象保持身份不变(syncEntriesInPlace 原地改字段)。pdf.js 每个视图都握着 store.database.files === 这张表;每拍换新数组就得每拍把所有 store 重绑一次 —— 那是高频改写别人的内部状态。现在绑定只在视图新出现时做一次。
  • 补拍定时器有硬上限(MAX_DEFER = 10):事件高频时直接丢弃并记账,不会无限堆积 —— 补拍每次都要读一遍宿主内部状态、合并一遍表,堆积起来会把主线程占住。被丢弃的数量会写进日志。
  • layout-change 有 400ms 节流:切视图、视图重建都会连着触发它,而一次就排 6 个补拍。被节流的次数也写进日志。
  • 调试日志文件封顶 2MB;onunload 把所有补拍定时器清干净。

同一份文件开在两个叶子里

两个视图各有各的位置,所以:只记录「活跃叶子」的位置,并且一个都不恢复。

  • 都记录 → 两边来回覆盖(日志里表现为页码 356 ↔ 1 交替出现);
  • 去恢复 → 把另一个窗格的人拽走。

日志里的排查线索

  • 视图就绪:… key=<叶子|路径>:同一个文件反复出现不同的 key,说明视图在被反复重建(那是吃内存的形态)。
  • 视图关闭:key=…:跟上一行对着看。
  • 记录位置:… 第 53 页 → 第 53 页(差异:scrollTop: 11→604):页码一样却判定变了时,直接告诉你是哪个字段在动。
  • 套用 PDF:… 想第 356 页,套用前第 1 页,读回第 356 页(共 653 页):分辨"跳页"是我们干的还是 pdf.js 自己重载后落的位。

记录不会被"还没恢复的默认值"覆盖

只要记录还在、与实况不同、我们试过却没能摆回去、而用户又没碰过它,就绝不用实况覆盖记录 —— 那一刻的实况只是"这个视图还没恢复(或恢复失败)",记下来就把要恢复的位置抹掉了。 (用户真操作过之后当然照实跟随;总开关关着、或压根没有同类记录时也一样。)


设置项

  • 接管文件列表(关掉后该节其余选项隐藏)
    • 启动时恢复展开状态
    • 完全还原:快照里没有的文件夹也主动收起(默认关,只展开不收起,更不容易误伤)
    • 挡掉可疑的整体折叠(默认开)
  • 接管阅读位置与视窗(关掉后该节其余选项隐藏)
    • 恢复阅读位置(总开关)
    • 接管 PDF / 接管 Markdown 滚动位置(默认开)/ 接管 Excalidraw 绘图(默认开)/ 接管核心画布(默认关)
  • 通用:调试日志(控制台输出,Ctrl+Shift+I 查看)

两边各有「立即恢复 / 记住当前 / 清除记录」三个动作项(点整行即执行)。

开启调试日志后,除了控制台还会往插件目录写一份 view-memory-debug.log: 启动那行带版本号与三个关键参数(就绪等待 / 恢复窗口 / 最多套用次数),之后每一次 「该套 / 跳过 / 交还 / 套用后读回多少」都会记下来。设置页的「当前状态」也会逐个视图列出 记录 vs 实况、已套用几次、用户是否操作过 —— 出问题先看这两处,不用猜。

命令面板

  • 文件列表:恢复到上次的展开状态
  • 文件列表:把当前展开状态记为基准
  • 文件列表:清除这个 vault 的展开记录
  • 阅读位置:把当前视图位置记为基准
  • 阅读位置:回到记录的阅读位置 / 视窗
  • 阅读位置:清除这个 vault 的位置记录

代码结构

src/folds.ts       折叠侧的纯函数(归一化 / 指纹 / judgeChange)
src/fold-engine.ts 折叠侧引擎(依赖全从 host 注入)
src/state.ts       视窗侧的纯函数(pdf 表合并 / 记录归一 / 视窗比对)
src/view-engine.ts 视窗侧引擎(依赖全从 host 注入)
src/main.ts        宿主适配:读实况 / 套回去 / 注册事件与命令
src/settings.ts    设置页

前四个文件不 import obsidian,所以能用 esbuild 打成 CJS 直接在 Node 里配假 host 跑完整链路。

测试

开发时用 Node 侧的桩测试回归(以忠实的最小 Obsidian 桩加载构建产物;套件随开发环境走,不随仓库发布):

  • 折叠侧回归:98 项
  • 视窗侧回归:199 项
  • 合并本身(命令 id 不撞车、配置分离、一个定时器驱动两边、记录迁移、设置页分节、启动引导、Markdown 记录真能套回视图、老配置缺字段也照样恢复、视图刚出现时的输入也算数、反复套用会停手、同文件双叶子只记活跃的那个、静止的 PDF 不写盘、补拍上限与节流):54 项

依赖的非公开成员(升级 Obsidian 后需重跑回归)

  • 文件列表:view.fileItems、item.collapsed、item.setCollapsed(v, animate)、tree.requestSaveFolds()
  • leaf 类型 ≠ 视图类型:Markdown 的 leaf 类型是 "markdown",不叫 "md"。main.ts 里 LEAF_TYPE 这张表就是干这个的,凡按类型找叶子都要走它 —— 直接拿 kind 去 getLeavesOfType() 会查不到叶子,而查不到只会安静 return null,表现成「记录存了但永远套不回去」。
  • PDF:view.viewer.child.pdfViewer → .store / .pdfViewer;view.setEphemeralState()
  • Markdown:view.currentMode.getScroll() / applyScroll(n)(源码与预览都实现)
  • Excalidraw:view.excalidrawAPI、view.preventAutozoom()、view._loaded、view.excalidrawData
  • Canvas:view.canvas.getState() / setState()

都做了 try/catch 兜底,缺了就跳过对应的那半边。

三个已经踩过的坑(改之前先看一眼)

  • 内部 API 的失败模式常常是"安静地什么都不做":renderer.applyScroll() 在内容没渲染完时 只 return false;getLeavesOfType() 查不到就返回空数组。所以要么走宿主自己的入口, 要么自己读回验证 + 落日志 —— 只写"我调用了",失败时等于没写。
  • 别把"实况变了"当成"用户在动":宿主自己会异步落位(md 渲染完摆滚动位置、pdf.js 自己读表)。 这条错误判据让 v1.2.3 的 md 恢复一次都没跑起来。
  • 配置合并要逐字段兜底:配置是浅合并(Object.assign(默认值, 存档)),存档里的 view 会整个替换默认的 view 对象 —— 于是"上次保存之后才新增的字段"就是 undefined。 而 x <= undefined 恒为 false:restoreWindowMs 曾因此丢掉,恢复窗口判定永远不成立, 恢复一次都不尝试、也不报错。所以 loadSettings 里每个数值项都要有默认值。