鸿蒙 PC Markdown 编辑器防误操作设计:未保存标签关闭状态机
2026/7/23 13:50:30 网站建设 项目流程

鸿蒙 PC Markdown 编辑器防误操作设计:未保存标签关闭状态机

桌面编辑器最不能犯的错误,是把“用户点击关闭”理解成“可以立即删除文档状态”。一个标签可能从未保存过,可能对应磁盘文件,可能在保存期间继续输入,可能遇到系统选择器取消、外部文件变化或写入失败。关闭动作如果只绑定一个布尔确认框,很容易在异步边界上丢失内容。

本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown,拆解脏标签关闭的取消、放弃、保存三条路径,说明为什么保存后关闭必须等待真正持久化成功,为什么后台标签要先激活再保存,以及如何在关闭最后一个标签时保持工作台可用。完整代码位于 https://gitcode.com/VON-/codex_md_oh,本文对应提交3a9146e

先区分关闭意图和关闭提交

用户点击标签关闭图标只表达一个意图。应用要先读取目标会话的dirty状态:

privaterequestCloseDocumentSession(sessionId:string):void{constsession=this.documentSessions.find((candidate:DocumentSession):boolean=>candidate.id===sessionId);if(!session){return;}if(!session.dirty){this.closeDocumentSession(sessionId);return;}// 脏标签进入确认流程}

这个入口名使用requestClose,而真正删除会话的方法叫closeDocumentSession,命名直接体现两阶段语义。干净标签没有数据风险,可以进入关闭提交;脏标签必须等待用户选择。

查询目标会话而不是读取页面级documentDirty非常重要。用户可能点击一个后台标签的关闭按钮,当前活动标签是干净的,后台标签却有星号。如果用全局活动状态判断,应用会无提示删除后台内容。多标签产品的任何命令都要明确作用对象,不能默认目标永远是活动会话。

如果 sessionId 已经过期或会话已被其他操作删除,函数直接返回。声明式 UI 点击事件与异步操作可能交错,业务层不应因为找不到对象而访问空值或误关当前标签。

三个按钮代表三种不同事务

脏标签确认对话框提供取消、放弃和保存:

this.getUIContext().getPromptAction().showDialog({title:$r('app.string.unsaved_changes_title'),message:$r('app.string.unsaved_changes_message'),buttons:[{text:$r('app.string.cancel_action'),color:'#34404B'},{text:$r('app.string.discard_action'),color:'#B42318'},{text:$r('app.string.save_action'),color:'#087A63'}]}).then((result)=>{if(result.index===1){this.closeDocumentSession(sessionId);}elseif(result.index===2){this.saveAndCloseDocumentSession(sessionId);}}).catch(()=>{this.operationStatus='Unable to show confirmation';});

索引零是取消,因此没有分支,状态保持不变。取消不是失败,也不需要把标签重新创建,因为它从未被删除。索引一是放弃,明确跳过保存并提交关闭。索引二是保存,进入一条异步链,不能直接调用关闭。

放弃按钮使用危险色,保存使用产品强调色,取消保持中性。颜色不是安全机制,真正安全性来自行为;视觉层只帮助用户在高频操作中区分后果。对话框按钮顺序也要与鸿蒙桌面交互习惯和键盘默认焦点一起验证,避免回车意外触发放弃。

showDialog返回 Promise,显示失败会落到 catch,只更新状态栏,不会关闭标签。确认 UI 自身异常时,保守策略必须是保留内容。所有错误路径都应倾向“不丢数据”,而不是为了结束流程强行删除状态。

保存按钮不等于保存成功

最危险的错误实现是:用户点击保存,程序发出写入请求,然后立刻关闭标签。如果这是未命名文档,系统保存选择器还没有返回;用户可能取消选择。如果目标文件无权限、磁盘已变化或写入失败,标签已经消失,编辑缓冲区也可能被清理。

OhMarkdown 使用pendingCloseSessionId表示“保存成功后需要关闭的会话”:

privatependingCloseSessionId:string='';privateasyncsaveAndCloseDocumentSession(sessionId:string):Promise<void>{if(sessionId!==this.activeDocumentSessionId){awaitthis.activateDocumentSession(sessionId);}if(sessionId!==this.activeDocumentSessionId){return;}this.pendingCloseSessionId=sessionId;this.requestEditorCommand('save');}

字段不是“关闭计时器”,而是保存事务结束后的条件动作。保存流程最终检查会话是否仍活动、是否已经干净;只有满足条件才真正关闭。

为什么后台标签需要先激活?当前文件保存管线读取页面级活动文档字段,包括 URI、正文、格式、revision 和持久化基线。直接保存后台会话会把当前页面字段与目标 sessionId 混用。先执行标准标签切换,让原生状态和 CodeMirror 状态都指向目标,再发出保存命令,可以复用经过验证的单活动会话保存路径。

切换是异步的,所以激活后再次比较 sessionId。ArkWeb 激活可能失败,或者文件操作互斥阻止切换;此时函数直接返回,既不设置 pending,也不关闭。重复检查看似保守,却封住了“以为切换成功”的竞态。

保存内容必须来自编辑器命令快照

requestEditorCommand('save')让 Web 编辑器先刷新原生状态和恢复快照,再通过 Bridge 发送保存命令。原生onEditorCommand接收正文:

privateonEditorCommand(command:string,content:string):void{if(command==='save'){this.documentContent=content;this.documentRevision+=1;this.syncActiveDocumentSession(this.documentContent);this.saveDocument(this.documentRevision);}// 省略其他命令}

这样保存使用的是用户点击时的编辑器快照,而不是原生层上一次节流同步的旧正文。保存调用同时携带snapshotRevision,后续可判断写入期间是否发生新编辑。

Bridge 协议在这里承担事务起点:编辑器先把当前内容固定为一个版本,原生层再写入。若直接由工具栏调用saveDocument,按键事件和 Bridge 节流之间可能有时间差,最后几个字符不一定进入保存内容。

保存期间设置operationInProgress,阻止另一个打开、保存或会话切换流程并发修改同一组活动字段。这个互斥让 pendingClose 只对应一笔保存。后续若支持多个后台会话并行保存,就需要把保存上下文从页面字段改成显式对象,包含 sessionId、URI、正文、格式和 revision,而不能仅去掉互斥。

系统选择器取消必须保留标签

未命名文档没有 URI,保存时先打开系统选择器:

letsaveUri=this.documentUri;constexistingDocumentUri=this.documentUri;if(saveUri.length===0){saveUri=awaitpickMarkdownSaveUri(context,this.documentName)??'';}if(saveUri.length===0){this.operationStatus=this.hasUnsavedChanges()?'Modified':'Ready';return;}

用户取消选择后saveUri为空,保存函数正常返回,documentDirty仍为 true。finally 会看到 pendingClose,但因为文档仍脏,不执行关闭。标签、正文和星号全部保留。

这一行为需要独立测试,因为从用户视角,他先在关闭确认框点“保存”,又在系统选择器点“取消”。第二次取消应当覆盖第一次保存意图,而不是被解释为“取消另存为但继续关闭”。多层对话框中,最内层未完成的持久化必须阻止外层破坏性动作。

混合换行文档还可能出现第二个策略对话框,让用户选择 LF、CRLF 或取消:

constsaveFormat=awaitthis.resolveSaveFormat();if(!saveFormat){this.operationStatus=this.hasUnsavedChanges()?'Modified':'Ready';return;}

取消格式选择同样保留 dirty,从而阻止关闭。状态机不是只围绕一个确认框,而是贯穿保存管线中所有可能终止事务的交互。

外部修改冲突也不能关闭

已有文件保存前会重新读取磁盘版本,与打开时记录的persistedDocumentContent和格式比较:

constdiskDocument=awaitreadUtf8Document(saveUri);if(existingDocumentUri.length>0&&this.persistedDocumentContent!==undefined&&(diskDocument.content!==this.persistedDocumentContent||!this.isSameDocumentFormat(diskDocument.format,this.documentFormat))){thrownewError('The file changed on disk. Reopen it or use Save As '+'to avoid overwriting external changes.');}

如果其他应用修改了同一文件,当前编辑器不能静默覆盖。异常进入 catch,文档继续保持 dirty,finally 不关闭。用户仍能从标签中复制内容、另存为或重新打开处理冲突。

这里再次说明关闭状态不能只依赖“保存函数有没有返回”。保存函数可能因为取消正常返回,也可能因为冲突抛错;最终判断应读取持久化后的事实,即目标会话是否干净。

写入失败时先恢复旧文件

保存前,程序把目标文件旧内容和格式写入应用沙箱备份:

pendingBackup={version:1,documentUri:saveUri,documentName:diskDocument.name,previousContent:diskDocument.content,hasUtf8Bom:diskDocument.format.hasUtf8Bom,lineEnding:diskDocument.format.lineEnding,updatedAt:Date.now()};awaitsavePendingSaveBackup(context.filesDir,pendingBackup);

真正写入失败后,catch 尝试把旧版本写回;恢复成功则清理备份,恢复也失败则保留沙箱记录供下次启动处理。无论哪种情况,当前编辑缓冲区都不应被删除。

}catch(error){constfailureMessage=errorinstanceofError?error.message:String(error);if(pendingBackup){try{awaitwriteUtf8Document(pendingBackup.documentUri,pendingBackup.previousContent,{hasUtf8Bom:pendingBackup.hasUtf8Bom,lineEnding:this.parseLineEnding(pendingBackup.lineEnding)});awaitclearPendingSaveBackup(context.filesDir);this.operationStatus=`Save failed; previous file restored:${failureMessage}`;}catch(_){this.operationStatus=`Save failed; backup retained:${failureMessage}`;}}}

关闭安全与文件安全在这里汇合:只要写入没有建立新的干净基线,pendingClose 就不能提交。即使旧磁盘文件已恢复,用户的新编辑仍只存在缓冲区,关闭会造成损失。

revision 防止保存期间的新输入被误判为已保存

保存开始时记录snapshotRevision。写入成功后,只有当前 revision 仍等于快照版本,才能清除 dirty:

if(this.documentRevision===snapshotRevision){this.documentDirty=false;this.operationStatus=backupCleared?'Saved':'Saved; backup cleanup pending';this.clearRecoveryDraft();}else{this.operationStatus='Modified';}

假设写入耗时较长,用户在保存过程中又输入一行。磁盘只包含保存开始时的快照,新一行尚未持久化。若无条件设为干净,标签星号消失,finally 随后关闭,最后一行就会丢失。revision 比较让它继续保持Modified,pendingClose 因此不执行。

这是一种乐观版本控制:写入操作可以异步完成,但提交“已保存”状态前确认数据没有变化。它比简单禁用编辑器更符合桌面体验,用户不必等待磁盘;代价是需要正确维护每次编辑的 revision。

markEditorSaved()会把 CodeMirror 内的保存基线更新到实际快照,原生层随后同步活动会话。保存基线、revision 和 dirty 三者必须一起迁移,不能只改标签星号。

finally 中的关闭条件

保存流程无论成功、取消还是失败,都会进入 finally:

}finally{constcloseSessionId=this.pendingCloseSessionId;this.pendingCloseSessionId='';if(closeSessionId.length>0&&closeSessionId===this.activeDocumentSessionId&&!this.documentDirty){awaitthis.closeDocumentSession(closeSessionId);}this.operationInProgress=false;}

先把 pending 字段复制到局部变量并清空,保证这笔意图只消费一次。随后检查三个条件:确实有待关闭会话;它仍是当前活动会话;文档已经干净。任何条件不满足都保留标签。

检查活动身份防止异步期间关闭错标签。当前互斥已大幅减少切换,但身份检查仍是必要防线。检查 dirty 则统一覆盖选择器取消、格式取消、外部冲突、写入失败、恢复失败和保存期间继续输入。

最后才把operationInProgress设回 false。closeDocumentSession内部还要切换后继会话并操作 ArkWeb,如果提前释放互斥,用户可能在关闭尚未完成时发出新命令。当前顺序让整个“保存并关闭”成为一个完整事务。

放弃关闭与恢复记录

用户选择放弃时,程序不写磁盘,直接删除目标会话。这是用户明确授权的数据丢弃路径。关闭活动会话后还会清理当前恢复草稿,避免下次启动又弹出已被用户放弃的内容。

但多标签崩溃恢复目前主要保证活动标签,后台标签的恢复记录尚未完整持久化。因此关闭后台脏标签时,放弃语义只作用于内存会话;未来扩展为多会话恢复集合后,必须按 sessionId 或 URI 删除对应记录,而不是清空全部草稿。

取消关闭则绝不能清理恢复记录。保存成功后可以清理,因为磁盘建立了新基线;放弃后可以清理,因为用户明确不要;显示对话框失败、系统选择器取消、写入失败都必须保留。

最后一个标签关闭后仍保留工作台

关闭提交从会话数组删除目标。如果删除后数组为空,程序创建一个新的干净未命名会话:

if(remainingSessions.length===0){remainingSessions=[createUntitledSession(this.createDocumentSessionId())];}

这样 Web 编辑器始终有活动 sessionId,工具栏的打开、新建、保存和快捷键不需要处理“零会话”特例。用户看到的是一个空白工作台,而不是编辑区域突然消失。

关闭中间标签时,后继选择原索引对应的右侧邻居;关闭末尾标签时选择新数组最后一项:

constnextIndex=Math.min(sessionIndex,remainingSessions.length-1);constnextSession=remainingSessions[nextIndex];this.applyDocumentSession(nextSession);awaitthis.activateEditorSession(nextSession);this.closeEditorSession(sessionId);

先激活后继,再清理旧 Web 会话,避免编辑器出现没有状态的中间帧。关闭非活动标签则无需切换,只删除数组项和对应快照。

鸿蒙 PC 模拟器中的脏标签

下图来自 MateBook Pro 2in1 模拟器。标签栏同时存在两个未命名文档,活动标签正文为Session-B,标签状态与底部Modified一致。这样的独立脏状态是关闭确认能够正确作用到目标标签的前提。

模拟器验证完成了取消和放弃分支:点击脏标签关闭图标,选择取消后标签和正文保留;再次关闭并选择放弃,只删除目标标签,邻近标签恢复且内容不串页。保存分支还要覆盖已有文件直接保存、未命名文件选择 URI、选择器取消、写入失败和保存期间继续输入。

测试报告不能把“调用保存方法”写成保存分支通过。真正通过标准是目标 URI 写入成功、dirty 清除后标签关闭;任何取消和失败都必须保留。状态机的验收围绕数据结果,而不是按钮路径。

应用退出与窗口关闭仍需单独设计

标签关闭状态机解决单个会话,不自动等于应用退出保护。用户点击窗口关闭时,可能有多个脏标签,需要决定逐个询问、汇总列表、全部保存或取消退出。若简单调用当前标签的关闭对话框,后台脏会话仍可能丢失。

完整退出流程可以收集所有 dirty 会话,显示可勾选列表,再按顺序保存。未命名文档会依次弹出系统选择器,任何取消都应中止退出。多个保存的错误汇总、部分成功后的状态、恢复记录清理也要定义。应用被系统强杀则不能弹框,只能依赖周期恢复快照。

因此,“关闭标签”“关闭窗口”“进程终止”是三种不同生命周期。它们可以复用会话 dirty 和保存事务,但不能共享一个简单确认函数后假设问题已经解决。

结语

未保存标签关闭是一台小型事务状态机:点击关闭只建立意图;干净标签直接提交;脏标签等待取消、放弃或保存;保存目标若在后台先完成会话切换;系统选择器、格式选择、外部冲突和写入异常都可以中止;revision 确认保存期间没有新输入;最终只在同一会话已经干净时关闭。

这套设计的核心标准很简单:所有不确定路径都保留用户内容,只有持久化成功或用户明确放弃才能删除会话。鸿蒙 PC 编辑器要进入长期真实写作场景,防误操作不能依赖一句“是否保存”,而要贯穿文件系统、Bridge、编辑器状态和多标签生命周期的完整调用链。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询