# 嵌入 Vue 工作台

在自己的 Vue 页面中嵌入共享工作台，明确样式、宿主请求和提交生命周期。

`TextDiagramEditor` 负责编辑草稿、预览、下载和提出保存请求。应用负责当前文档、持久化以及是否接受修改。工作台不直接写入你的编辑器或服务器。

## 样式与主题

从 `@quirekit/text-diagram-editor/vue` 导入组件，同时导入 `@quirekit/text-diagram-editor/styles.css`。界面控件还需要 `@quirekit/unocss-preset` 的生成样式，以及 `@quirekit/ui/vue` 的主题、语言和图标 Provider。

按[设计系统开始使用](http://127.0.0.1:3200/components/getting-started)配置 `defineQuireKitUnoConfig`、UnoCSS Vite 插件和 `virtual:uno.css`；本地预览中的设计站使用固定端口 3200。将 `QuireThemeProvider`、`QuireLocaleProvider`、`QuireIconProvider` 放在应用根，图标使用 `@quirekit/ui` 的 `quireIconRegistry`。本站已经由共享外壳配置这些 Provider。

## 完整可运行接入

先看 `workspace/editor.vue`：它在挂载后提供 `ownerDocument`，从宿主取得 `request`，再把两者交给工作台。点击保存后，宿主决定是否接受修改。

| 文件 | 要修改什么时查看 |
| --- | --- |
| `workspace/editor.vue` | 组件挂载、保存与取消后的页面行为 |
| `recipes/session.ts` | 当前对象、版本比较和本地存储 |
| `shared/locale.ts` | 示例界面文案 |
| `shared/types.ts` | 宿主共享类型 |

示例使用浏览器本地存储；替换 `persist` 回调可接到自己的同步本地事务。源码页签一次查阅一个文件。

### 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: "弹窗使用独立副本，不会修改原模板。",
  };
}

```

## 组件合同

| 属性 / 事件 | 用途 |
| --- | --- |
| `request` | 必填，包含基线 `document`、同步 `commit` 和 `release` |
| `ownerDocument` | 必填，挂载后的浏览器 Document |
| `locale` | 控件与新模板的语言；默认中文，支持英文 |
| `languages` | 可选，仅开放已安装的语言；省略时为三种语言 |
| `titleEditable` | 需要标题输入时显式设为 `true`；Markdown 宿主可设为 `false` |
| `saved` | 宿主返回 `saved` 后触发，应用刷新基线或关闭工作区 |
| `cancel` | 用户取消，应用释放请求并决定下一步 |

`request.document` 在组件创建时作为基线。切换到另一对象或接受保存后，先释放旧请求，再用新的 `key` 挂载组件。不要仅替换 `request` 期待当前草稿自动重置。

## 释放与 SSR

内嵌组件不会替应用释放宿主目标；在取消、重建或卸载时调用 `request.release()`。组件自身会释放预览和 CodeMirror。`ownerDocument` 在 `onMounted` 后赋值；Nuxt 通过 `ClientOnly` 或 `.client.vue` 页面边界挂载工作台，服务端不访问 `document`。

源码和预览必须一起提交，完整合同见[保存与恢复](/guide/persistence)。
