README file from
GithubView 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() 让它自己那份存储也刷新一遍。
三道防线(最关键的部分)
这类"替宿主补做"的插件,最大的风险不是没修好,而是把好快照洗掉 —— 那比不修还糟:
armed之前一个字都不写盘。 恢复完成前观察到的任何状态都不可信(此刻界面可能正处于"丢失"的样子)。fileItems为空 = 状态未知 → 跳过。 不能把"条目还没建好"当成"用户把整棵树收起来了",否则真正的状态出现后反而永远修不回来。实现上currentSig()返回string | null,null表示未知。- 启动宽限期(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 条 反复被覆盖。
修法四层:
- 每秒把「磁盘上的表 + 我们按路径存的记录 + 打开视图的实况」合成一张(优先级 磁盘 < 记录 < 实况),并让所有打开的 PDF 视图共用同一个
database对象 —— 它们写的是同一份,互相覆盖从根上没了。 - 监听
file-open,在 pdf.js 读表之前把该文件记录写回 localStorage → 它自己的恢复链路(连页内滚动位置一起)就正常工作了,不用我们去算滚动坐标。 - 兜底
setEphemeralState({ subpath: "#page=N" })深链跳页。 - 另按文件路径存一份记录(免疫"内容指纹变化"和"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。如果发现它也丢,设置里把开关打开即可。
五道防线
-
视图未就绪(
ready=false)跳过。 -
套用后 2.5s 静默期:期内不回读、不重复套 —— 否则我们刚写的值立刻被当成"用户的新状态"读回来,把记录冲成中间态。("已经到位"这个记账不受静默期影响,否则它要等 2.5 秒才被认下来。)
-
★ 判断「用户在动」只认真实输入事件(
wheel/touchstart/touchmove/pointerdown/keydown),而且必须落在这个视图自己的容器里(按「叶子 + 路径」记账,点侧边栏不算)。 绝不能拿「实况变了」来代替 —— 宿主自己也会异步落位:md 渲染完成后会自己把滚动位置摆一下、pdf.js 会自己去读那张表。把它们当成"用户已经接手",就会永久交还、恢复一次都不跑(v1.2.3 的「完全没动」就是这么来的,日志里连一条「套用」都没有)。 所以click与scroll也不能收:它们同样会被宿主的程序化滚动触发。 另外,我们「看见」视图的时刻 ≠ 视图出现的时刻:tick 一秒一拍,用户完全可能在视图刚出现时就滚了,而那一下发生在我们第一次看见它之前。这种输入照样算数(给了 1.5s 的观察余量),否则就会出现「切到 PDF 滚一下,零点几秒后被拽回原页」。 用户一被认成"接手",该视图就永久交还(handsOff),之后只记录。 -
★ 一个视图只摆有限次(这条是「PDF 来回闪」的根因):命中任一即永久交还 ——
- 用户真的操作过它(见第 3 条);
- 已经和记录到位了;
- 试满
maxAttempts。
少了这条,"摆回记录位置"会变成长期悬在头上的纠正:用户从第 15 页翻到 16 页,过一会儿被拽回 15,再跳回 16,来回闪。
-
恢复阶段有时间上限(
restoreWindowMs,默认 6s,从视图第一次就绪算起):过了就只记录、不再套用。 -
同一文件连续套用 6 次仍不成功就停手:防的是"视图被反复重建 → attempts 被重置 → 我们跟着反复套用"那种失控(画面会反复跳,还可能把宿主拖垮)。成功到位就把计数清零,所以正常地反复开关同一份文件不受影响。
-
页码超出文档范围就不跳:记录里的页码大于这篇 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里每个数值项都要有默认值。