# 在线工作台

编辑源码、切换模板、预览与下载，并在当前浏览器保存图形。

选一种语言开始编辑；选择模板后还需要点击“应用模板”。三个语言各自保留本次编辑的草稿与撤销历史。切换界面语言不会翻译已有源码。

### workspace/editor.vue

```vue
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
import { QuireButton } from "@quirekit/ui/vue";
import { createTextDiagram, readTextDiagram, serializeTextDiagram, textDiagramMarkdown, textDiagramTemplates,
  type TextDiagramEditRequest, type TextDiagramEmbed } from "@quirekit/text-diagram";
import { TextDiagramEditor } from "@quirekit/text-diagram-editor/vue";
import "@quirekit/text-diagram-editor/styles.css";
import { createDiagramSession } from "../recipes/session";
import { exampleText } from "../shared/locale";
import type { DiagramSession } from "../shared/types";

const props = defineProps<{ /** Control language; existing diagram text remains unchanged. */ locale?: string }>();
const t = computed(() => exampleText(props.locale));
const owner = shallowRef<Document>(), request = shallowRef<TextDiagramEditRequest>(), saved = shallowRef<TextDiagramEmbed>();
const status = ref<"unsaved" | "saved" | "restored" | "cancelled">("unsaved"), storageError = ref(false), generation = ref(0);
const storageKey = "quirekit-text-diagram-playground-v1";
let session: DiagramSession;

/** Releases the old target before mounting an editor with a fresh baseline. */
function renew(): void {
  request.value?.release(); request.value = session.capture(); generation.value++;
}
onMounted(() => {
  owner.value = document;
  const template = textDiagramTemplates(props.locale)[0]!;
  let initial = createTextDiagram("playground", template.language, template.source);
  try {
    const stored = window.localStorage.getItem(storageKey);
    if (stored) { initial = readTextDiagram(JSON.parse(stored)); status.value = "restored"; }
  } catch { storageError.value = true; }
  session = createDiagramSession(initial, next => window.localStorage.setItem(storageKey, serializeTextDiagram(next)));
  saved.value = initial; renew();
});
/** Reflects only an accepted save; failed storage leaves the source editor mounted. */
function accept(): void { saved.value = session.read(); status.value = "saved"; storageError.value = false; renew(); }
/** Discards the current working copy, keeping the accepted host revision. */
function cancel(): void { status.value = "cancelled"; renew(); }
/** Downloads the entire validated saved object, including its source and PNG. */
function download(): void {
  if (!saved.value?.preview || !owner.value) return;
  const view = owner.value.defaultView!, link = owner.value.createElement("a");
  link.download = "diagram.json";
  link.href = view.URL.createObjectURL(new Blob([serializeTextDiagram(saved.value)], { type: "application/json" }));
  link.click(); setTimeout(() => view.URL.revokeObjectURL(link.href), 1000);
}
onBeforeUnmount(() => request.value?.release());
</script>
<template>
  <div class="diagram-demo" data-diagram-playground data-qk-not-prose>
    <p class="diagram-demo__hint">{{ t.persistence }}</p>
    <p v-if="storageError" role="alert">{{ t.storage }}</p>
    <TextDiagramEditor v-if="owner && request" :key="generation" :request="request" :owner-document="owner" :locale="locale" :title-editable="true" @saved="accept" @cancel="cancel" />
    <div class="diagram-demo__result">
      <output role="status" data-save-status>{{ t[status] }}<template v-if="saved?.preview"> · {{ t.revision }} {{ saved.document.revision }}</template></output>
      <QuireButton :disabled="!saved?.preview" variant="outline" @click="download">{{ t.json }}</QuireButton>
    </div>
    <details v-if="saved?.preview"><summary>{{ t.result }}</summary><pre data-saved-source>{{ textDiagramMarkdown(saved.document.language, saved.document.source) }}</pre></details>
  </div>
</template>
<style scoped>
.diagram-demo { display:grid;gap:20px;min-width:0;padding:20px; }
.diagram-demo__hint { margin:0;color:var(--qk-color-text-secondary);font-size:13px;line-height:1.6; }
.diagram-demo__result { display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;border-top:1px solid var(--qk-color-border-subtle);padding-top:16px;font-size:13px; }
pre { overflow:auto;max-height:240px;font-size:12px;white-space:pre; }
summary { cursor:pointer;font-size:13px; }
@media (max-width:560px) { .diagram-demo { padding:12px; } }
</style>

```

### recipes/session.ts

```ts
import { readTextDiagram, serializeTextDiagram, type TextDiagramEmbed } from "@quirekit/text-diagram";
import type { DiagramSession } from "../shared/types";

/**
 * Creates a single-object host with compare-and-save semantics.
 * @param initial - Validated persisted object or new draft.
 * @param persist - Optional synchronous storage write; throwing leaves the baseline unchanged.
 * @returns A host whose requests cannot overwrite a newer accepted revision.
 */
export function createDiagramSession(initial: TextDiagramEmbed, persist?: (next: TextDiagramEmbed) => void): DiagramSession {
  let current = readTextDiagram(initial);
  return {
    /** @returns The immutable accepted revision. */
    read() { return current; },
    /** @returns A detached baseline with explicit commit and release callbacks. */
    capture() {
      const baseline = current, expected = serializeTextDiagram(baseline);
      let released = false;
      return {
        document: baseline,
        /** @param proposal - Source and preview proposed by the workbench. @returns Atomic host acceptance. */
        commit(proposal) {
          if (released) return "missing";
          if (serializeTextDiagram(current) !== expected) return "conflict";
          const next = readTextDiagram(proposal);
          if (next.id !== baseline.id || next.document.revision !== baseline.document.revision + 1 || !next.preview) {
            throw new TypeError("Invalid diagram save");
          }
          // Storage must succeed before publishing the new revision to the UI.
          persist?.(next);
          current = next;
          return "saved";
        },
        /** Closes this target without changing the accepted object. */
        release() { released = true; },
      };
    },
  };
}

```

### shared/types.ts

```ts
import type { TextDiagramEditRequest, TextDiagramEmbed, TextDiagramSnapshot, TextDiagramRaster } from "@quirekit/text-diagram";

/** Minimal synchronous host; durable storage belongs to its caller. */
export interface DiagramSession {
  /** @returns The last accepted immutable object. */
  read(): TextDiagramEmbed;
  /** @returns A new edit request capturing the current baseline. */
  capture(): TextDiagramEditRequest;
}

/** Two output formats generated from exactly the same source. */
export interface DiagramFiles {
  /** Sanitized vector snapshot. */
  readonly vector: TextDiagramSnapshot;
  /** Bounded PNG projection. */
  readonly raster: TextDiagramRaster;
}

```

### shared/locale.ts

```ts
/** @param locale - Host interface language. @returns Demonstration labels; source is never translated. */
export function exampleText(locale = "zh-CN") {
  return locale.startsWith("en") ? {
    saved: "Saved in this browser", restored: "Restored the last saved diagram", cancelled: "Draft cancelled; showing the last saved version",
    unsaved: "No saved diagram yet", storage: "The saved diagram could not be read. You can edit a new draft; saving will replace that local entry.",
    persistence: "Save diagram stores the source and PNG in this browser. Unsaved edits are discarded when you leave this page.",
    result: "Last saved Markdown", json: "Download saved JSON", revision: "Revision", open: "Open diagram editor",
    close: "Editor closed", memory: "This dialog example keeps accepted changes until you leave the page.",
    templates: "Template catalog", language: "Language", template: "Template", try: "Try this template",
    original: "Template source", selected: "The dialog uses a detached copy; the template stays unchanged.",
  } : {
    saved: "已保存到当前浏览器", restored: "已恢复上次保存的图形", cancelled: "草稿已取消，已回到上次保存的版本",
    unsaved: "尚未保存图形", storage: "无法读取已保存的图形。可以编辑新草稿；保存将替换这一条本地记录。",
    persistence: "“保存图形”将源码与 PNG 保存在当前浏览器。离开本页会丢弃尚未保存的修改。",
    result: "上次保存的 Markdown", json: "下载已保存 JSON", revision: "修订号", open: "打开绘图编辑器",
    close: "编辑器已关闭", memory: "此弹窗示例在离开页面前保留已保存的修改。",
    templates: "模板目录", language: "绘图语言", template: "模板", try: "试用这个模板",
    original: "模板源码", selected: "弹窗使用独立副本，不会修改原模板。",
  };
}

```

## 保存到哪里

“保存图形”保存到当前站点的浏览器本地存储，没有上传服务。刷新或再次进入本页会恢复最后成功保存的版本；**离开本页会丢弃未保存的修改**。“取消”回到最后保存的版本。

SVG / PNG 下载基于当前有效预览，不要求先保存。JSON 下载包含最后保存的源码、语言、标题、修订号及 PNG；Markdown 视图仅包含标准围栏与源码，不包含标题或预览图片。

浏览器禁用存储或空间不足时，保存会失败并保留当前草稿，可以继续下载 SVG / PNG。此示例是单页面本地宿主，不提供云同步或多标签页协作。

## 键盘与错误恢复

| 操作 | 按键 |
| --- | --- |
| 缩进 / 减少缩进 | Tab / Shift-Tab |
| 离开源码区 | 先按 Esc，再按 Tab |
| 查找替换 | Ctrl / ⌘ F |
| 撤销 | Ctrl / ⌘ Z |

修改后稍等即可看到新预览；中文组合输入完成后再渲染。源码无效、预览过期或仍在渲染时不能保存。修正源码或点击“刷新预览”重试，原文仍在。

模板和完整语法的关系见[模板目录](/reference/templates)，保存合同见[保存与恢复](/guide/persistence)。
