# 读取与保存

按需读取 Markdown，通过增量事件连接存储与外部更新。

## 读取完整源码

保存按钮、导出或提交时调用 `getMarkdown()`。读取结果是 Markdown 文本，保留编辑器支持的原始文件格式策略。

```ts
const markdown = editor.getMarkdown()
// 将 markdown 交给应用自己的持久化接口。
```

示例中的「读取 Markdown」只生成当前快照。之后继续编辑时，需要再次读取才能得到最新文本。

### basic/editor.vue

```vue
<script setup lang="ts">
import { computed, ref, shallowRef } from "vue";
import { useMsfeEditor, VueNodeViewHost } from "@quirekit/msfe-vue";
import { StarterKit } from "@quirekit/msfe-starter-kit";
import { QuireButton, QuireSegmented, QuireProseSource } from "@quirekit/ui/vue";
import { Appearance } from "../shared/appearance";
import { exampleMessages } from "../shared/messages";
import type { ExampleLocale } from "../shared/types";

const props = withDefaults(defineProps<{ /** Language supplied by the application. */ locale?: ExampleLocale }>(), { locale: "zh-CN" });
const text = computed(() => exampleMessages[props.locale]);
const initial = text.value.initial;
const parent = shallowRef<HTMLElement | null>(null);
const mode = ref("live");
const markdown = ref<string>();
const items = computed(() => [{ value: "live", label: text.value.live }, { value: "source", label: text.value.source }, { value: "locked", label: text.value.locked }]);

// One editor instance owns source, selection and history for all three modes.
const { editor, host } = useMsfeEditor(parent, () => ({ markdown: initial, mode: "live",
  extensions: [StarterKit, Appearance], label: text.value.editor }));

/** @param value - Mode selected in the shared control. */
function changeMode(value: string | null | undefined): void {
  if (value === "source" || value === "live" || value === "locked") { mode.value = value; editor.value?.setMode(value); }
}
/** Serializes only for an explicit save/read action; typing never mirrors the entire source. */
function readMarkdown(): void { markdown.value = editor.value?.getMarkdown(); }
/** Starts a fresh document and history after the user explicitly resets this example. */
function reset(): void { editor.value?.replaceDocument(initial, "example-reset"); markdown.value = undefined; }
</script>
<template>
  <div class="grid gap-4 p-qk-surface">
    <div class="flex flex-wrap items-center justify-between gap-3">
      <QuireSegmented :model-value="mode" :items="items" :label="text.modes" size="sm" @update:model-value="changeMode" />
      <div class="flex gap-2">
        <QuireButton size="sm" variant="ghost" :disabled="mode === 'locked'" @click="editor?.undo()">{{ text.undo }}</QuireButton>
        <QuireButton size="sm" variant="ghost" @click="reset">{{ text.reset }}</QuireButton>
        <QuireButton size="sm" variant="outline" @click="readMarkdown">{{ text.readMarkdown }}</QuireButton>
      </div>
    </div>
    <div ref="parent" class="overflow-hidden rounded-qk-control border border-qk-border-subtle" />
    <VueNodeViewHost :host="host" />
    <QuireProseSource v-if="markdown !== undefined" :code="markdown" language="markdown" filename="document.md" :max-height="260" />
  </div>
</template>

```

### shared/appearance.ts

```ts
import { Extension } from "@quirekit/msfe-core";
import { EditorView } from "@quirekit/cm-view";
import { Prec } from "@quirekit/cm-state";
import { HighlightStyle, syntaxHighlighting } from "@quirekit/cm-language";
import { tags } from "@lezer/highlight";
import { themeVariableReferences as token } from "@quirekit/ui";

/** Consumer-owned theme; source semantics stay in the installed feature packages. */
export const Appearance = Extension.create({ name: "documentationAppearance",
  /** @returns Scoped typography, surfaces and syntax colors from the host design system. */
  addCodeMirrorExtensions() {
    return Prec.high([
      EditorView.theme({
        "&": { ...token.typography.body, color: token.color.text.primary, backgroundColor: token.color.surface.page, minHeight: "180px" },
        ".cm-scroller": { fontFamily: "inherit", lineHeight: "1.8" },
        ".cm-content": { padding: token.spacing.surface, caretColor: token.color.text.primary },
        ".cm-line": { padding: "0" },
        ".cm-gutters": { color: token.color.text.tertiary, backgroundColor: token.color.surface.subtle, border: "none" },
        ".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "transparent" },
        ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { backgroundColor: token.color.selection.document },
        ".cm-cursor": { borderLeftColor: token.color.text.primary },
        ".msfe-h1": { ...token.typography.pageTitle },
        ".msfe-h2, .msfe-h3": { ...token.typography.sectionTitle },
        ".msfe-heading": { paddingBlock: token.spacing.inline },
        ".msfe-link": { color: token.color.text.accent },
        ".msfe-code-line, .msfe-inline-code": { ...token.typography.code },
        ".consumer-status": { verticalAlign: "middle" },
        "&.cm-focused": { outline: "none" },
      }),
      syntaxHighlighting(HighlightStyle.define([
        { tag: tags.heading, color: token.color.text.primary, fontWeight: "600" },
        { tag: tags.strong, fontWeight: "700" },
        { tag: tags.emphasis, fontStyle: "italic" },
        { tag: [tags.link, tags.url], color: token.color.text.accent },
        { tag: tags.processingInstruction, color: token.color.text.tertiary },
        { tag: tags.monospace, color: token.color.text.secondary },
      ])),
    ]);
  },
});

```

### shared/messages.ts

```ts
const chinese = {
  editor: "Markdown 示例编辑器", modes: "编辑器模式", source: "源码", live: "实时预览", locked: "只读",
  undo: "撤销", redo: "重做", reset: "重置", readMarkdown: "读取 Markdown", readingPreview: "阅读预览", insertStatus: "插入状态", done: "已完成", todo: "待处理",
  nativeEditor: "原生 DOM 示例编辑器", statusEditor: "自定义状态编辑器",
  initial: "## 把想法写下来\n\n在这里编辑 **Markdown**，也可以切换到源码。\n\n- [x] 保留 Markdown 原文\n- [ ] 试试修改这一行\n",
  nativeInitial: "## 原生 DOM 接入\n\n这个实例直接使用 `createMsfeEditor()`。\n",
  statusInitial: "## 自定义状态\n\n设计评审：[[todo]]\n\n点击状态按钮，然后读取 Markdown 或撤销。\n",
};
const english: { [Key in keyof typeof chinese]: typeof chinese[Key] } = {
  editor: "Markdown example editor", modes: "Editor mode", source: "Source", live: "Live", locked: "Locked",
  undo: "Undo", redo: "Redo", reset: "Reset", readMarkdown: "Read Markdown", readingPreview: "Reading preview", insertStatus: "Insert status", done: "Done", todo: "Pending",
  nativeEditor: "Native DOM example editor", statusEditor: "Custom status editor",
  initial: "## Write your ideas\n\nEdit **Markdown** here, or switch to source.\n\n- [x] Preserve the Markdown source\n- [ ] Try editing this line\n",
  nativeInitial: "## Native DOM integration\n\nThis instance uses `createMsfeEditor()` directly.\n",
  statusInitial: "## Custom status\n\nDesign review: [[todo]]\n\nClick the status, then read the Markdown or undo.\n",
};
export const exampleMessages = { "zh-CN": chinese, en: english };

```

### shared/types.ts

```ts
/** Example host language; independent of the documentation router. */
export type ExampleLocale = "zh-CN" | "en";

```

## 订阅增量

`onChange` 提供 `documentId`、`revision`、`changes`、`selection` 和 `origin`。可以标记未保存状态，或交给支持增量的宿主存储；无需为渲染工具栏而在每次输入时读取全文。

```ts
const unsubscribe = editor.onChange(event => {
  console.log('文档版本', event.documentId, event.revision)
})

// 观察者结束生命周期时取消订阅。
unsubscribe()
```

自动保存时，由应用安排保存频率并处理失败、重试和提交顺序。不要把服务端保存响应直接当成需要再次替换全文的输入。

## 外部更新

`applyChanges()` 接收预期的文档身份与基础版本。偏移必须属于该版本，旧版本会被拒绝；失败后重新取得最新状态并处理冲突。

```ts
const result = editor.applyChanges({
  documentId: editor.documentId,
  baseRevision: editor.revision,
  changes: { from: 0, to: 0, insert: '新增说明。\n\n' },
  origin: 'host',
})

if (!result.ok) console.warn(result.reason)
```

版本化修改接口本身不构成多人协同协议。切换到另一份文档时，使用 `replaceDocument(markdown, documentId)`。

## 可直接接入的顺序自动保存

下面的 TypeScript 模块把同一份文档的保存请求串行化，延迟读取快照；源码继续变化时，当前请求完成后再保存新版本。错误后保留 dirty 状态，下一次编辑或显式 `flush()` 重试。它不把响应写回编辑器。

### recipes/autosave.ts

```ts
import type { AutosaveOptions, AutosaveSession, SaveSnapshot } from "./types";
/** @param options - Host persistence policy and one current document. @returns Serial save ownership for that document only. */
export function attachAutosave(options: AutosaveOptions): AutosaveSession {
  const { editor } = options;
  const scope = editor.documentScope.fork();
  let dirty = false;
  let timer: ReturnType<typeof setTimeout> | undefined;
  let running: Promise<void> | undefined;
  /** Writes successive immutable snapshots; slow responses never replace the open source. */
  async function drain(): Promise<void> {
    while (dirty && !scope.disposed) {
      dirty = false;
      const snapshot: SaveSnapshot = { documentId: editor.documentId, revision: editor.revision, markdown: editor.getMarkdown() };
      options.status("saving");
      try {
        await options.save(snapshot, scope.signal);
        if (scope.disposed) return;
        if (!dirty && editor.revision === snapshot.revision) options.status("saved");
      } catch (error) {
        if (!scope.disposed) { dirty = true; options.status("error"); options.error(error); }
        return; // An explicit flush or the next source edit retries; no busy retry loop.
      }
    }
  }
  /** @returns Completion of prior work and any source dirty when this flush can acquire the writer. */
  async function flush(): Promise<void> {
    clearTimeout(timer);
    // Recheck after awaiting: another flush may have acquired the single writer.
    while (running) await running;
    if (scope.disposed || !dirty) return;
    running = drain().finally(() => { running = undefined; });
    await running;
  }
  scope.own(editor.onChange(() => {
    dirty = true;
    options.status("dirty");
    clearTimeout(timer);
    timer = setTimeout(() => { void flush(); }, options.delay ?? 600);
  }));
  scope.own(() => clearTimeout(timer));
  return { flush, dispose: () => scope.dispose() };
}

```

### recipes/types.ts

```ts
import type { MsfeEditor } from "@quirekit/msfe-core";
/** One immutable save request, captured before starting network work. */
export interface SaveSnapshot {
  /** Host document identity. */ readonly documentId: string;
  /** Local revision, not a server ETag. */ readonly revision: number;
  /** Exact serialized Markdown. */ readonly markdown: string;
}
/** Host-owned persistence and feedback; the adapter does not assume a backend protocol. */
export interface AutosaveOptions {
  /** Current mounted instance. */ readonly editor: MsfeEditor;
  /** Delay after the latest source change. */ readonly delay?: number;
  /** @param snapshot - Captured source. @param signal - Document lifetime. @returns Completion after durable storage. */
  readonly save: (snapshot: SaveSnapshot, signal: AbortSignal) => Promise<void>;
  /** @param status - Current state of this adapter. */ readonly status: (status: "dirty" | "saving" | "saved" | "error") => void;
  /** @param error - Persistence failure; the draft stays dirty for an explicit retry. */ readonly error: (error: unknown) => void;
}
/** Serial autosave session; create a fresh one after replacing the document. */
export interface AutosaveSession {
  /** @returns Completion after all currently dirty source is saved, or a reported error. */ flush(): Promise<void>;
  /** Cancels timers, subscription and in-flight document work. */ dispose(): void;
}

```

~~~ts
import { attachAutosave } from './recipes/autosave'
const saving = attachAutosave({
  editor,
  save: async (snapshot, signal) => {
    const response = await fetch(`/api/documents/${encodeURIComponent(snapshot.documentId)}`, {
      method: 'PUT', signal, headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
      body: snapshot.markdown,
    })
    if (!response.ok) throw new Error(`save-http-${response.status}`)
  },
  status: value => { console.log('保存状态', value) },
  error: error => { console.error(error) },
})
// 页面离开前先等待 flush，并由应用根据保存状态决定是否允许离开。
await saving.flush()
~~~

`/api/documents/:id` 需要应用提供。这个 adapter 从首次源码变化开始保存，不会自动把刚加载的文档标为 dirty。`flush()` 结束不代表一定成功：失败由 status/error 报告，应用必须保留错误状态和重试入口。

## 服务器并发和离线

本地 `revision` 是编辑器内计数，不是服务器版本。多客户端写入时，服务端还需要 ETag、版本号或协同协议；遇到 409/412 由应用进行合并或冲突选择，不能无限重试覆盖他人的保存。

离线缓存属于宿主存储层，可缓存精确 Markdown 和服务端版本。没有 durable acknowledgement 前，不应显示“已保存到服务器”。不要在 beforeunload 中假设可以完成一个任意异步请求。

## 换文档与外部回声

顺序是：处理当前未保存状态、结束旧保存 session、`replaceDocument()`、给新文档创建新 session。旧 `documentScope` 会取消仍在处理的资源。

同文档外部修改用 `applyChanges()`，偏移属于 `baseRevision` 的逻辑文本。成功时得到新的 revision；`origin` 可标记外部来源，宿主可在 onChange 里抑制同源回声。发生 stale-target 时，不要只把 baseRevision 改成最新值后重复旧 changes，必须重新映射或合并。

## 格式和存储职责

编辑器接收解码后的字符串。文件编码、BOM 的文件字节处理、文件名、权限、附件存储和下载都由应用负责。需要纯文本快照时用 `getMarkdown()`；需要编辑偏移时用逻辑 `state.doc`。不要把渲染 HTML 或组件状态当作可恢复的 Markdown 存档。
