单个像素画布能保存,并不意味着编辑器已经具备项目管理。用户真正需要的是:新建一个项目不会覆盖旧画布,切换项目前会把当前修改收束,重命名不会让列表和当前标题不一致,删除当前项目后还能自然回到一个可用状态。bitArt 把这些动作放在 EditorProjectWorkflowService 中,页面只负责打开管理面板和传入用户选择。
项目切换的第一原则是先收束当前会话
如果项目切换时直接 loadProject,正在进行的笔触可能还没有结束,自动保存队列也可能没有把最新像素写盘。用户看到的现象是切回来少了一笔,或者项目列表的更新时间不对。删除当前项目也不能只删文件:当前页面仍持有旧 projectId,下一次保存会把一个“已删除项目”写回去。
项目面板和持久化服务如何配合
项目管理的关键不是列出一堆文件,而是让当前会话能安全交给下一份项目。笔触、动画和保存队列需要在切换前被明确收束。
管理模式和打开模式不是同一个点击语义
当前打开项目的流程先判断是否处于管理模式。管理模式下点击卡片只切换选中状态,不加载项目;普通模式下如果点击当前项目则关闭面板,点击其他项目才依次停止动画、结束笔触、保存当前项目、读取目标项目、应用持久化状态、重新安排自动保存并刷新摘要。这个顺序把“项目切换”从一个跳转动作变成一个带保护的会话交接。
项目名称也有独立的草稿字段。提交时先用 EditorPersistenceService.normalizeProjectName 取得有效名称,再根据它是不是当前项目决定走 saveNow 还是单独 renameProject。这样列表里的非当前项目不需要被完整加载就能改名。
切换项目的顺序必须先结束当前会话,再读取目标项目。
static async openProject(target: EditorProjectWorkflowTarget, context: common.UIAbilityContext | null, projectId: string, hooks: EditorProjectWorkflowHooks): Promise<void> { if (context === null || projectId.length === 0) return; if (target.projectManageMode) { hooks.toggleProjectSelection(projectId); return; } if (projectId === target.currentProjectId) { target.projectManagerOpen = false; return; } hooks.stopAnimationPlayback(); hooks.endStroke(); await hooks.saveNow(); const project = await EditorPersistenceService.loadProject(context, projectId); if (project !== null) { hooks.applyPersistentProject(project); hooks.scheduleAutoSave(); target.projectManagerOpen = false; await hooks.refreshProjectSummaries(); } }重命名与删除都需要明确的后续状态
项目管理中最重要的不是循环,而是状态机。一个项目卡片在普通模式代表“打开”,在管理模式代表“选择”;删除动作完成后,如果删掉的是当前项目,工作流要从刷新后的摘要中打开第一个剩余项目,若一个也没有则创建默认项目。每一步都有明确的来源和去向,因此不会把空 projectId 留给后续导出或保存。
const fallbackName = EditorProjectPanelService.getProjectNameFallback(target.projectSummaries, projectId, target.canvasWidth, target.canvasHeight); const normalizedName = EditorPersistenceService.normalizeProjectName(target.editingProjectNameText, fallbackName); if (EditorProjectPanelService.updateProjectNameDraft(target, projectId, normalizedName)) { hooks.markProjectDirty(); } if (projectId === target.currentProjectId) { target.currentProjectName = normalizedName; await hooks.saveNow(true); } else { await EditorPersistenceService.renameProject(context, projectId, normalizedName); }把当前会话交给下一个项目之前
saveCurrentProject、openProjectManager 与 openProject 都先收束动画和笔触。项目面板的 selectedProjectIds 只服务于批量操作,currentProjectId 才是当前工作区的唯一来源。刷新摘要后再关闭面板,可以避免 UI 先关闭却仍显示旧列表的短暂错位。
项目摘要、当前项目 ID 和管理模式各有职责,刷新摘要不能替代应用实际项目数据。
const deletingCurrent = target.selectedProjectIds.indexOf(target.currentProjectId) >= 0; await hooks.saveNow(true); await EditorProjectActionService.deleteProjects(context, target.selectedProjectIds.slice()); target.selectedProjectIds = []; await hooks.refreshProjectSummaries(); if (deletingCurrent) { await EditorProjectWorkflowService.openFallbackProjectAfterDeletion(target, context, defaultCanvasSize, hooks); }项目管理不能省略的保护步骤
- 切换项目前先结束笔触和保存,不能依赖页面销毁时的偶然写盘。
- 管理模式下的点击只能改变 selectedProjectIds,不能同时打开项目。
- 删除当前项目后必须立即选择后备项目或创建默认项目,不能留下悬空 currentProjectId。
项目管理的价值在于把“可编辑状态”从页面生命周期里拿出来。用户可以创建多个尺寸不同的画布,保存它们各自的图层、帧、调色板和设置,之后在管理器里切换而不需要重新初始化工作区。对于创作工具,这比单纯加一个“新建”按钮更接近真实使用场景。
用两个项目验证切换顺序
多项目管理最适合用“两个不同状态”的对照实验验证。项目 A 使用 32×64 画布、两层和一个自定义色;项目 B 使用 64×64 画布、三层和不同活动帧。在 A 中完成一笔还未显式点击保存的绘制后切到 B,再回到 A,检查像素、尺寸、图层顺序、当前工具和颜色。若任一字段回到默认值,说明保存或 applyPersistentProject 的边界不完整。
删除路径也不能只看文件是否消失。删除非当前项目后,当前工作区应稳定;删除当前项目后,应自动进入另一个可用项目;删除最后一个项目时,应产生默认项目而不是空白页面。项目管理的好体验来自这些后续状态,而不是管理器里那个删除按钮本身。把这组回归固定下来,后续加导入、合并或批量操作时也能及时发现会话交接被破坏。
怎样确认项目切换不会丢修改
项目检查应覆盖当前项目与非当前项目两条路径,尤其要验证删除后的后备状态。
- 建立两个不同尺寸、不同图层数的项目,切换前后确认每个项目保留自己的像素和活动图层。
- 在管理模式中点按多个项目,确认只改变选中状态,不改变当前画布。
- 重命名当前项目与非当前项目,关闭并重开管理器,确认名称一致。
- 删除当前项目、删除非当前项目和删除最后一个项目,确认都有明确的后续工作区。
项目管理检查记录 切换前收束笔触并保存 管理模式只选择,不打开 改名后摘要和当前标题一致 删除当前项目后有可编辑的后备项目本地项目管理的范围
当前项目管理针对单设备本地项目,不包含多人协作、云端版本分支或跨设备冲突解决。项目文件导入导出提供了可迁移入口,但不等同于实时同步。
这一篇的重点是把项目切换做成一次安全的会话交接。项目可以并列存在之后,下一篇继续处理更难的情况:怎样把多个项目合成一个新项目,而不覆盖原稿。