# 保存、打开与文件转换

为 Classic 接入未保存状态、版本化 JSON 和 DOCX 服务，并处理保存失败与格式损失。

Classic 不会自动把正文写进你的数据库。应用负责保存位置、文档身份和未保存提示；产品提供当前内容及已安装的格式服务。本页从已挂载的 `instance` 开始，创建方式见[快速接入](/classic/quick-start)。

## 1. 记录真正的正文变化

产品插件的 `onUpdate` 还会响应选区和界面刷新，不适合直接当作“正文已修改”。在插件的 `onMount` 订阅当前 Core 的 `update`，比较前后文档身份，在 `onUnmount` 解除订阅。结构更新重建 Core 后会重新绑定；语言、只读和内置主题更新保留原订阅。

### classic/persistence/observer.ts

```ts
import type { QuireEditor, QuireEditorUpdateEvent } from "@quirekit/core";
import { defineClassicEditorPlugin } from "@quirekit/classic-editor";
import type { ClassicEditorPlugin } from "@quirekit/classic-editor";

/**
 * Observes document changes across product remounts without treating UI updates as edits.
 * @param onChange - Host callback that marks the current document as unsaved.
 * @returns Product plugin; create a separate plugin object for each Classic instance.
 */
export function createClassicDraftObserver(onChange: () => void): ClassicEditorPlugin {
  let owner: QuireEditor | undefined;

  /** @param event - Accepted update, which can also represent an editability change. */
  function onUpdate(event: QuireEditorUpdateEvent): void {
    // Core already suppresses equal-content updates; an editability notification
    // retains the same immutable document. The observer needs only identity.
    if (event.previousState.doc !== event.editor.state.doc) onChange();
  }

  /** Releases the subscription belonging to the previous mounted Core. */
  function detach(): void {
    owner?.off("update", onUpdate);
    owner = undefined;
  }

  return defineClassicEditorPlugin({
    id: "app.draft-observer",
    /** @param context - Current mount, whose Core owns this subscription. */
    onMount(context) {
      detach();
      owner = context.editor;
      owner?.on("update", onUpdate);
    },
    onUnmount: detach,
    onDestroy: detach,
  });
}

```

创建时把 `createClassicDraftObserver(() => { dirty = true })` 加入实例的 `plugins`。`dirty` 是应用自己的状态；一个插件对象只交给一个实例。例程不在每次变化时序列化全文，切换只读也不会误报正文修改。显式使用 `emitUpdate: false` 的恢复流程应由宿主建立新的已保存基线。

## 2. 保存一个确定的版本

`instance.getJSON()` 读取当前规范内容。备份还应带上自己的版本、模型和文档 ID；文档名称可通过 `instance.setDocumentName(name)` 更新，名称由应用另行持久化，不包含在 `getJSON()` 中。

下面的 Word 草稿例程先捕获内容，再等待存储确认。返回 `true` 才表示当前页面仍停留在刚保存的版本；返回 `false` 表示这次写入完成了，但期间发生了编辑、重挂载或退出，不应清除未保存提示。

### classic/persistence/save.ts

```ts
import type { ClassicDraftOwner, ClassicDraftRepository } from "./types";

/**
 * Saves one revision without clearing changes made while storage was pending.
 * @param instance - Mounted product that owns the current document.
 * @param repository - Application storage; it must serialize writes for the same document.
 * @returns Whether the acknowledged revision still belongs to the active Core and document.
 */
export async function saveClassicDraft(
  instance: ClassicDraftOwner,
  repository: ClassicDraftRepository
): Promise<boolean> {
  const editor = instance.editor;
  if (instance.isDestroyed || !editor || editor.isDestroyed) {
    throw new Error("Mount the document before saving");
  }

  // Capture before awaiting: a later edit or product remount owns a different revision.
  const document = editor.state.doc;
  const source = JSON.stringify({ version: 1, model: "word-v1", content: instance.getJSON() });
  await repository.write(source);

  return !instance.isDestroyed && !editor.isDestroyed
    && instance.editor === editor && editor.state.doc === document;
}

```

### classic/persistence/types.ts

```ts
import type { ClassicEditorInstance } from "@quirekit/classic-editor";

/** Application-owned destination for one explicitly captured draft. */
export interface ClassicDraftRepository {
  /**
   * Persists one complete serialized draft, rejecting on failure.
   * @param source - Versioned JSON captured when saving began.
   * @returns Completion after storage acknowledges the write.
   */
  write(source: string): Promise<void>;
}

/** Product facts required to determine whether a saved draft is still current. */
export type ClassicDraftOwner = Pick<ClassicEditorInstance, "editor" | "getJSON" | "isDestroyed">;

```

`repository.write(source)` 由你连接接口或本地存储，失败应拒绝 Promise。保存按钮在请求期间禁用，或由 repository 串行写同一文档；返回值只保护界面的保存状态，不能阻止服务端并发请求倒序覆盖。多用户编辑还需要版本冲突协议。这个例程只保存 JSON，图片字节仍需[持久媒体服务](/build/resources)。

## 3. 打开 JSON 备份

先读取原文件，检查模型、版本、节点、标记、属性和 Schema，再提交。完整校验顺序见[内容校验](/guide/content)与[文件操作](/build/files)。损坏文件不能替换当前草稿。

内置 `json` 服务读取原生规范文档 JSON，并通过相同的提交边界严格替换；它不识别本页例程带有版本、模型和 `content` 包装的业务备份。业务备份先由应用解包校验，再按下面的流程打开。

打开另一份业务文档时，先处理当前未保存修改，再建立新会话边界：

```ts [提交已经校验的备份]
instance.update({ document: validatedContent })
instance.setDocumentName('项目说明')
```

这会销毁旧 Core 并用新正文挂载；原撤销历史不会保留。插件通过挂载钩子重新订阅。应用同步切换文档 ID、存储键和已保存基线，并取消旧文档的保存任务。不要在输入事件中反复传入 `document`。

只恢复当前会话正文时，可以使用 Core 的 `replaceDocument()`，它连同顶层属性替换且本次不计入历史；这与创建新会话的生命周期不同。

## 4. 装配 DOCX / DOTX 服务

`createClassicWordDocxPlugin()` 默认注册 `docx`、`dotx` 两个服务。它们与 Word preset 配合，通过 `plugins` 安装一次。预设本身不代表这些服务已经注册。

```ts [在快速接入的 plugins 中安装]
plugins: [createClassicWordDocxPlugin({
  onReport(report) {
    showConversionReport(report.warnings, report.diagnostics)
  },
})]
```

`showConversionReport` 是你的界面函数：区分解析警告与内容近似、保留或丢失的诊断。导入成功不代表完全无损；不符合 Schema 的块可能被降级为纯文本并报告 `schema-invalid-block`。不要把一次成功返回显示成“格式完全保留”。配置见 [DOCX 插件参考](/plugins/reference/create-classic-word-docx-plugin)。

## 5. 调用文件服务

```ts [传入文件字节并取得导出产物]
await instance.importDocument('docx', {
  data: new Uint8Array(await file.arrayBuffer()),
  fileName: file.name,
  mimeType: file.type,
})
const artifact = await instance.exportDocument('docx')
```

`file` 是宿主选择的 `File`。DOCX 需要字节，不能传文本；DOTX 使用 `dotx` 服务 ID。导出返回 `data`、`fileName`、`mimeType`，宿主负责下载或上传，成功生成文件不等于数据库已保存。服务不存在、没有对应操作或产品未挂载时，调用会拒绝 Promise；转换失败也需要捕获并展示。

## 6. 取消与防止迟到覆盖

`importDocument()` 为每次调用捕获当前 Core 与正文版本。新的导入、正文修改（包括修改后撤销）、只读配置更新、卸载或销毁，会让尚未提交的旧导入失效。移动光标不使正文版本过期。官方 DOCX 服务在最后的同步提交边界复核这些条件，过期时拒绝为 `AbortError`，保留当前正文与名称，也不发成功转换报告。

```ts [宿主取消一次打开操作]
const opening = new AbortController()
try {
  const data = new Uint8Array(await file.arrayBuffer())
  await instance.importDocument('docx', {
    data, fileName: file.name, mimeType: file.type,
  }, { signal: opening.signal })
} catch (error) {
  if (!(error instanceof DOMException && error.name === 'AbortError')) throw error
}
// 用户取消、选择另一文件或退出时，由宿主调用 opening.abort()
```

在读取文件字节前建立 controller；选择新文件时先取消上一次，防止旧文件的 `arrayBuffer()` 迟到后才启动导入。Abort 可以及时结束调用方等待，但底层不支持取消的 codec 仍可能完成内部解析；最终提交仍被拦截。已经提交的写入不能通过随后 abort 撤销。

只读会在导入入口与提交前各检查一次，应用还需检查业务授权。若要打开完全不同的业务文档，仍需处理未保存修改、存储键和会话边界；导入不是文档管理系统。

## 7. 自定义 import 的迁移

自定义服务先解析和校验，再在 `context.commit(() => editor.commands.replaceDocument(...))` 中执行一次同步命令，并在提交后报告成功。不要把 async 回调传给 commit，也不要在 commit 外直接写正文。旧插件若绕过这个合同直接调用命令，运行时无法回滚其任意副作用；应升级插件后再依赖过期保护。完整上下文见 [Classic API](/classic/api)。

导出是否捕获点击时快照由服务实现决定；官方 DOCX 服务会捕获开始时的正文。导入图片的暂存与失败清理见[持久媒体](/classic/media)。自己开发 Core 产品时，仍使用[解析、校验、提交的宿主流程](/build/files)。

实例和配置的完整字段见[Classic 实例与配置参考](/classic/reference)。
