# 独立弹窗

从任意浏览器宿主打开工作台，传递主题并在关闭时释放请求。

需要“点击按钮 → 编辑图形 → 回到原页面”的流程时，使用 `openTextDiagramDialog`。它创建独立 Vue 根并按需加载工作台，包含自己的 Provider；宿主仍需安装[工作台样式](/guide/vue)。

### dialog/launcher.vue

```vue
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, shallowRef } from "vue";
import { QuireButton } from "@quirekit/ui/vue";
import { createTextDiagram, textDiagramTemplates } from "@quirekit/text-diagram";
import { openTextDiagramDialog, type TextDiagramDialogHandle } from "@quirekit/text-diagram-editor";
import "@quirekit/text-diagram-editor/styles.css";
import { createDiagramSession } from "../recipes/session";
import { exampleText } from "../shared/locale";
import { dialogTheme } from "../shared/theme";

const props = defineProps<{ /** Dialog control language. */ locale?: string }>();
const t = computed(() => exampleText(props.locale));
const sample = textDiagramTemplates(props.locale).find(item => item.language === "plantuml")!;
const session = createDiagramSession(createTextDiagram("dialog-example", sample.language, sample.source));
const accepted = shallowRef(session.read()), status = ref<"unsaved" | "close">("unsaved");
const lifetime = new AbortController();
let dialog: TextDiagramDialogHandle | undefined;

/** Opens a request owned by this example, releasing it exactly once on close. */
function open(): void {
  dialog?.close();
  const request = session.capture();
  dialog = openTextDiagramDialog({
    ownerDocument: document, locale: props.locale, theme: dialogTheme(document), signal: lifetime.signal,
    request: { ...request,
      /** @param next - Exact source and PNG. @returns Host acceptance before the dialog closes. */
      commit(next) { const result = request.commit(next); if (result === "saved") accepted.value = session.read(); return result; },
      /** Cancels the target when the dialog is saved, cancelled or removed. */
      release() { request.release(); status.value = "close"; },
    },
  });
}
onBeforeUnmount(() => lifetime.abort());
</script>
<template>
  <section class="dialog-example" data-dialog-example data-qk-not-prose>
    <p>{{ t.memory }}</p>
    <div><QuireButton color="primary" @click="open">{{ t.open }}</QuireButton></div>
    <output role="status">{{ t[status] }} · {{ t.revision }} {{ accepted.document.revision }}</output>
    <img v-if="accepted.preview" :src="accepted.preview.dataUrl" :alt="accepted.title" />
  </section>
</template>
<style scoped>
.dialog-example { display:grid;gap:16px;padding:20px;font-size:13px; }
p { margin:0;color:var(--qk-color-text-secondary); }
img { max-width:100%;max-height:360px;object-fit:contain;background:white; }
</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: "弹窗使用独立副本，不会修改原模板。",
  };
}

```

### shared/theme.ts

```ts
import { createTheme, type QuireKitTheme } from "@quirekit/ui";

/** @param owner - Document with a host-controlled CSS color scheme. @returns Theme snapshot for an independent dialog root. */
export function dialogTheme(owner: Document): QuireKitTheme {
  const scheme = owner.defaultView!.getComputedStyle(owner.documentElement).colorScheme;
  return createTheme({ scheme: scheme === "dark" ? "dark" : "light" });
}

```

示例初始为 PlantUML 时序图，保存后显示 PNG，再次打开会读取已接受的新版本。取消不改变原对象；离开页面后这个演示内存会话结束。

## 主题与生命周期

传入实际的 `ownerDocument`、`locale` 和宿主 `theme`；主题不写入文档，预览保留引擎或源码定义的样式。示例根据页面的 CSS `color-scheme` 生成主题，真实应用可以直接传入自己的 `QuireKitTheme`。

返回值是立即可用的 `TextDiagramDialogHandle`。即使懒加载还没完成，也可以调用 `close()`；重复关闭是安全的。提供 `signal` 可以在宿主卸载时统一关闭。

弹窗在保存成功、取消、外部关闭或加载失败时调用 `request.release()`。不要把 `release` 当成保存通知：它也会在取消时执行。保存结果以 `commit` 返回 `saved` 为准。

## 焦点和提交

源码区的 Esc 先处理查找或退出编辑焦点；需要移出源码区时先按 Esc 再按 Tab。弹窗关闭后焦点回到触发位置。提交返回 `conflict`、`readonly`、`missing` 时，工作台显示原因并保留当前草稿。

`commit` 是同步的本地接受合同，不能返回 Promise。需要服务器持久化的应用，应先定义本地事务与同步策略；不要提前返回 `saved` 却没有接受对象。详见[持久化合同](/guide/persistence)和[弹窗 API](/api/editor)。
