# 图表与代码块预览

从 Mermaid 流程图到 ECharts，构建具有加载、失败回退和清理的源码预览。

## 可操作的完整示例

上方流程图使用内置 `Mermaid`；下方柱图是本页提供的自定义 `echarts` 围栏扩展。点击编辑源码、改值、移开选区，图表会依据同一段 Markdown 更新。读取 Markdown 可以验证渲染没有重新序列化围栏。

### charts/editor.vue

```vue
<script setup lang="ts">
import { StarterKit } from "@quirekit/msfe-starter-kit";
import { Mermaid } from "@quirekit/msfe-markdown-extras";
import { VueComponentViewRenderer } from "@quirekit/msfe-vue";
import EditorExample from "../shared/editor-example.vue";
import type { ExampleLocale } from "../shared/types";
import { Charts } from "./extension";
import PreviewControls from "./preview-controls.vue";
const props = withDefaults(defineProps<{ /** Host UI language. */ locale?: ExampleLocale }>(), { locale: "zh-CN" });
const english = props.locale === "en";
const flow = english ? "Draft --> Review --> Publish" : "Draft[草稿] --> Review[评审] --> Publish[发布]";
const markdown = `${english ? '# Diagrams from source' : '# 源码中的图表'}\n\n~~~mermaid\nflowchart LR\n  ${flow}\n~~~\n\n~~~echarts\n{"type":"bar","labels":["A","B","C"],"values":[12,20,16]}\n~~~\n`;
const mermaid = Mermaid.configure({ controls: VueComponentViewRenderer(PreviewControls) });
</script>
<template><EditorExample :locale="locale" :markdown="markdown" :extensions="[StarterKit, mermaid, Charts]" /></template>

```

### shared/editor-example.vue

```vue
<script setup lang="ts">
import { computed, provide, ref, shallowRef } from "vue";
import { useMsfeEditor, VueNodeViewHost } from "@quirekit/msfe-vue";
import type { AnyExtension, CodeLanguage } from "@quirekit/msfe-core";
import { QuireButton, QuireSegmented, QuireProseSource } from "@quirekit/ui/vue";
import { Appearance } from "./appearance";
import { exampleMessages } from "./messages";
import { exampleLocaleKey } from "./locale";
import type { ExampleLocale } from "./types";
const props = defineProps<{
  /** Initial canonical source; changing this prop does not replace an open document. */ markdown: string;
  /** Explicitly installed feature descriptors. */ extensions: readonly AnyExtension[];
  /** Language of host controls. */ locale: ExampleLocale;
  /** Optional exact lazy code-language registry. */ codeLanguages?: readonly CodeLanguage[];
  /** A product preset already supplies the public Prose presentation. */ prose?: boolean;
}>();
const text = computed(() => exampleMessages[props.locale]);
provide(exampleLocaleKey, computed(() => props.locale));
const parent = shallowRef<HTMLElement | null>(null);
const snapshot = ref<string>();
const mode = ref("live");
const items = computed(() => [{ value: "live", label: text.value.live }, { value: "source", label: text.value.source }, { value: "locked", label: text.value.locked }]);
const { editor, host } = useMsfeEditor(parent, () => ({ markdown: props.markdown, mode: "live", label: text.value.editor,
  extensions: [...props.extensions, ...(props.prose ? [] : [Appearance])], codeLanguages: props.codeLanguages }));
/** @param value - Requested display mode. */
function changeMode(value: string | null | undefined): void {
  if (value === "live" || value === "source" || value === "locked") { mode.value = value; editor.value?.setMode(value); }
}
</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="outline" @click="snapshot = editor?.getMarkdown()">{{ text.readMarkdown }}</QuireButton>
      </div>
    </div>
    <slot :editor="editor" />
    <div ref="parent" class="overflow-hidden rounded-qk-control border border-qk-border-subtle" />
    <VueNodeViewHost :host="host" />
    <QuireProseSource v-if="snapshot !== undefined" :code="snapshot" language="markdown" filename="document.md" />
  </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/locale.ts

```ts
import type { ComputedRef, InjectionKey } from "vue";
import type { ExampleLocale } from "./types";

/** Host UI language shared through the existing Vue provider tree, never stored in Markdown. */
export const exampleLocaleKey: InjectionKey<ComputedRef<ExampleLocale>> = Symbol("example-locale");

```

### shared/types.ts

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

```

### charts/extension.ts

```ts
import { Node, defineSource, componentView } from "@quirekit/msfe-core";
import { EditorSelection } from "@quirekit/cm-state";
import { chartRenderer } from "./renderer";
/** Closed top-level echarts fences, rendered without rewriting their JSON or fence spelling. */
export const Charts = Node.create({ name: "exampleCharts",
  /** @returns Semantic recognition; this example does not provide a JSON field-editing command. */
  addSource() {
    return defineSource({ nodes: ["FencedCode"],
      /** @param document - Canonical source. @param node - Confirmed syntax. @returns Recognized data or a native source fallback. */
      read(document, node) {
        const info = node.child("CodeInfo");
        if (node.parentKind !== "Document" || node.children("CodeMark").length !== 2 || !info
          || document.slice(info.from, info.to).trim().toLowerCase() !== "echarts") return null;
        return { text: node.children("CodeText").map(part => document.slice(part.from, part.to)).join("") };
      },
      /** @returns Read-only projection: edit its canonical JSON in source instead. */
      patch() { return null; },
    });
  },
  /** @returns A direct structural layer, available before viewport layout. */
  addProjectionLayers() {
    const owner = this;
    return [{ key: "exampleCharts.preview", placement: "block",
      /** @param input - Accepted source and current permissions. @returns Declarative replacements; selection reveal stays with core. */
      build(input) {
        return input.query(owner.name).matches.map(match => {
          const from = input.document.lineAt(match.from).from, to = match.to;
          const source = input.document.slice(from, to);
          return { type: "replace" as const, from, to, key: String(match.from),
            view: componentView(chartRenderer, { text: String(match.attrs.text), source, readOnly: input.readOnly,
              /** @returns False if source has changed since these props were accepted. */
              reveal() {
                const editor = owner.editor;
                if (editor.scope.disposed || editor.state.doc.sliceString(from, to) !== source) return false;
                editor.setMode("live");
                editor.setSelection(EditorSelection.single(editor.state.doc.lineAt(from).to + 1));
                editor.focus();
                return true;
              },
            }),
          };
        });
      },
    }];
  },
});

```

### charts/renderer.ts

```ts
import { VueComponentViewRenderer } from "@quirekit/msfe-vue";
import type { ComponentViewRenderer } from "@quirekit/msfe-core";
import ChartView from "./chart-view.vue";
import type { ChartProps, ChartViewProps } from "./types";
const vueRenderer = VueComponentViewRenderer<ChartViewProps>(ChartView);
/** @param mount - Runtime-owned DOM, provider host and lifetime. @returns Retained Vue view with the same ownership across updates. */
export const chartRenderer: ComponentViewRenderer<ChartProps> = mount => {
  const view = vueRenderer({ ...mount, props: { ...mount.props, scope: mount.scope, requestMeasure: mount.requestMeasure } });
  return {
    /** @param props - Newly accepted canonical inputs. */
    update(props) { view.update({ ...props, scope: mount.scope, requestMeasure: mount.requestMeasure }); },
    /** Releases the portal; runtime independently ends the scope. */
    destroy() { view.destroy(); },
  };
};

```

### charts/chart-view.vue

```vue
<script setup lang="ts">
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
import { QuireButton, QuireProseSource } from "@quirekit/ui/vue";
import type { TaskScope } from "@quirekit/msfe-core";
import type { ChartViewProps } from "./types";
import { parseChartData } from "./data";
import { exampleLocaleKey } from "../shared/locale";
import { exampleMessages } from "../shared/messages";
const props = defineProps<{ /** Accepted source and runtime-owned view lifetime. */ view: ChartViewProps }>();
const locale = inject(exampleLocaleKey, computed(() => "zh-CN"));
const english = computed(() => locale.value === "en");
const text = computed(() => exampleMessages[locale.value]);
const surface = shallowRef<HTMLElement | null>(null);
const status = ref<"loading" | "ready" | "error">("loading");
let request: TaskScope | undefined;
/** Cancels previous work before loading another source generation. */
function render(): void {
  request?.dispose();
  status.value = "loading";
  const source = props.view.text;
  const lifetime = props.view.scope.fork(() => { lifetime.dispose(); status.value = "error"; void nextTick(props.view.requestMeasure); });
  request = lifetime;
  lifetime.acquire(async signal => {
    const data = parseChartData(source);
    const echarts = await import("echarts");
    if (signal.aborted || !surface.value) return { value: null };
    const element = surface.value;
    const chart = echarts.init(element, undefined, { renderer: "svg", locale: english.value ? "EN" : "ZH" });
    const releaseChart = lifetime.own(() => chart.dispose());
    // Host text color is resolved from design tokens; JSON never supplies scripts or HTML.
    chart.setOption({ animation: false, grid: { left: 44, right: 16, bottom: 32, top: 24 },
      xAxis: { type: "category", data: [...data.labels] }, yAxis: { type: "value" },
      series: [{ type: data.type, data: [...data.values] }], tooltip: { show: false } });
    /** Updates visual colors without replacing source, chart identity or editor state. */
    function recolor(): void {
      const color = getComputedStyle(element).color;
      chart.setOption({ color: [color], textStyle: { color }, xAxis: { axisLabel: { color } }, yAxis: { axisLabel: { color } } });
    }
    recolor();
    const resize = new ResizeObserver(() => { chart.resize(); props.view.requestMeasure(); });
    lifetime.own(() => resize.disconnect());
    resize.observe(element);
    const theme = new MutationObserver(recolor);
    lifetime.own(() => theme.disconnect());
    theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-qk-scheme", "style", "class"] });
    return { value: chart, release() { resize.disconnect(); theme.disconnect(); releaseChart(); } };
  }, chart => { if (chart) { status.value = "ready"; void nextTick(props.view.requestMeasure); } });
}
onMounted(render);
watch(() => props.view.text, render);
onBeforeUnmount(() => request?.dispose());
</script>
<template>
  <section class="grid gap-2 rounded-qk-control border border-qk-border-subtle p-qk-surface" :aria-label="english ? 'Chart preview' : '图表预览'" :data-chart-status="status">
    <div class="flex items-center justify-between gap-2">
      <span class="text-qk-text-secondary">ECharts</span>
      <div class="flex gap-2">
        <QuireButton v-if="status === 'error'" size="sm" variant="outline" @click="render">{{ english ? 'Retry' : '重试' }}</QuireButton>
        <QuireButton size="sm" variant="ghost" :disabled="view.readOnly" @click="view.reveal">{{ english ? 'Edit source' : '编辑源码' }}</QuireButton>
      </div>
    </div>
    <p v-if="status !== 'ready'" role="status">{{ status === 'loading' ? (english ? 'Loading chart…' : '正在加载图表…') : (english ? 'Invalid chart data. Edit the JSON and try again.' : '图表数据无效，请修改 JSON 后重试。') }}</p>
    <div ref="surface" class="h-64 min-w-0 w-full text-qk-text-accent" role="img" :aria-label="english ? 'Values by category' : '分类数据图表'" :aria-hidden="status !== 'ready'" />
    <QuireProseSource v-if="status === 'error'" :code="view.source" filename="chart.md" language="markdown" />
  </section>
</template>

```

### charts/types.ts

```ts
import type { TaskScope } from "@quirekit/msfe-core";
/** Immutable source-derived chart inputs; no executable code is accepted. */
export interface ChartProps {
  /** JSON payload read from a confirmed fence. */ readonly text: string;
  /** Exact full fence retained as the fallback. */ readonly source: string;
  /** Effective editing permission. */ readonly readOnly: boolean;
  /** @returns Whether the original source can still be revealed. */ readonly reveal: () => boolean;
}
/** Runtime ownership supplied by the component renderer. */
export interface ChartViewProps extends ChartProps {
  /** View lifetime; each load gets its own child lifetime. */ readonly scope: TaskScope;
  /** Notify CodeMirror after preview geometry changes. */ readonly requestMeasure: () => void;
}
/** Deliberately small, validated chart data contract, serialized as JSON. */
export interface ChartData {
  /** Category labels, at most 50. */ readonly labels: readonly string[];
  /** One finite number per category. */ readonly values: readonly number[];
  /** Supported series family. */ readonly type: "bar" | "line";
}

```

### charts/data.ts

```ts
import type { ChartData } from "./types";
/** @param text - Source-owned JSON. @returns Validated bounded data; arbitrary ECharts options are intentionally unsupported. */
export function parseChartData(text: string): ChartData {
  if (text.length > 16_000) throw new Error("chart-too-large");
  const data: unknown = JSON.parse(text);
  if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("invalid-chart");
  const value = data as Record<string, unknown>;
  if (Object.keys(value).some(key => !["labels", "values", "type"].includes(key))
    || !Array.isArray(value.labels) || !Array.isArray(value.values)
    || value.labels.length < 1 || value.labels.length > 50 || value.values.length !== value.labels.length
    || !value.labels.every(label => typeof label === "string" && label.length <= 80)
    || !value.values.every(number => typeof number === "number" && Number.isFinite(number))
    || (value.type !== "bar" && value.type !== "line")) throw new Error("invalid-chart-data");
  return { labels: value.labels, values: value.values, type: value.type };
}

```

### charts/preview-controls.vue

```vue
<script setup lang="ts">
import { computed, inject } from "vue";
import type { PreviewControlProps } from "@quirekit/msfe-markdown-extras";
import { QuireButton } from "@quirekit/ui/vue";
import { exampleLocaleKey } from "../shared/locale";
defineProps<{ /** Shared built-in preview actions, including failure recovery. */ view: PreviewControlProps }>();
const locale = inject(exampleLocaleKey, computed(() => "zh-CN"));
const english = computed(() => locale.value === "en");
</script>
<template><div class="flex justify-end gap-2">
  <QuireButton v-if="view.status === 'error'" size="sm" variant="outline" @click="view.retry">{{ english ? 'Retry' : '重试' }}</QuireButton>
  <QuireButton size="sm" variant="ghost" :disabled="view.readOnly" @click="view.revealSource">{{ english ? 'Edit diagram source' : '编辑图示源码' }}</QuireButton>
</div></template>

```

## 内置文本绘图与自定义图表

| 场景 | 建议 |
| --- | --- |
| Mermaid 的 flowchart、sequenceDiagram 等 | 安装 `Mermaid`，使用 `mermaid` 围栏 |
| PlantUML 时序、类、用例、组件、状态、部署等图 | 安装 `TextDiagrams`，使用 `plantuml` 或 `puml` 围栏 |
| Graphviz 关系、分组架构与依赖图 | 安装 `TextDiagrams`，使用 `dot` 或 `graphviz` 围栏 |
| 自己的 `echarts` JSON 数据协议 | 使用本例 codec、块级投影和受控渲染器 |
| `flow` / flowchart.js 或其他 DSL | 自定义围栏识别和引擎适配器；不是内置语言别名 |

这里的 Mermaid 流程图正文以 `flowchart LR` 开始，围栏仍叫 `mermaid`。不要把 Mermaid flowchart 与 flowchart.js 的 DSL 当作同一种语法；内置扩展不会自动把 `flow` 交给另一个引擎。

## 安装依赖和已有能力

~~~sh
pnpm add '@quirekit/msfe-markdown-extras@workspace:*' echarts
~~~

~~~ts
import { Mermaid } from '@quirekit/msfe-markdown-extras'
import { VueComponentViewRenderer } from '@quirekit/msfe-vue'
import PreviewControls from './preview-controls.vue'

const diagrams = Mermaid.configure({
  controls: VueComponentViewRenderer(PreviewControls),
})
~~~

内置 `Mermaid` 已管理关闭围栏识别、异步渲染、失败时显示源码、重试和释放。其公开配置目前是预览操作 `controls`，没有名为 `render` 的任意引擎替换参数。需要完全不同的渲染过程时，创建自己的扩展。

当前内置渲染启用严格安全模式、禁用 HTML 标签并校验 SVG；不接受文档内初始化指令，存在源码长度和边数量限制。无法渲染时保留可读源码。语法范围由所安装的 Mermaid 版本决定，参见 [Mermaid 官方用法](https://mermaid.js.org/config/usage.html)。

## PlantUML 与 Graphviz/DOT

独立 MSFE 宿主安装 `TextDiagrams`；MD Editor 默认已安装。它与 `Mermaid` 分别识别自己的语言，不争抢同一个围栏：

~~~ts
import { Mermaid, TextDiagrams } from '@quirekit/msfe-markdown-extras'

const extensions = [Mermaid, TextDiagrams]
~~~

~~~~markdown
```plantuml
@startuml
客户端 -> 服务端: 请求
服务端 --> 客户端: 响应
@enduml
```

```dot
digraph G {
  rankdir=LR;
  草稿 -> 审阅 -> 发布;
}
```
~~~~

[MD Editor](/md-editor) 的“插入 → 文本绘图”打开与 Classic、Block 共用的源码工作台。保存后仍是标准 Markdown 围栏；取消或渲染失败不会提交草稿，已有错误源码继续可读、可修改。`features.extras.mermaid` 与 `features.extras.textDiagram` 分别控制 Mermaid 和 PlantUML / DOT 能力。

## 文本绘图独立文档

通用工作台、25 个双语模板、CodeMirror 快捷键、SVG / PNG 下载、独立 Vue 接入和资源部署统一在[文本绘图文档站](http://127.0.0.1:3260/)维护。可以直接打开[在线工作台](http://127.0.0.1:3260/playground)或[支持范围](http://127.0.0.1:3260/reference/support)。以上为本地固定端口预览，规划域名为 `text-diagram.quirekit.com`。

本页继续说明 MSFE 的围栏集成与自定义图表扩展。PlantUML 使用本地官方 JavaScript 核心，语法与外观不承诺同完整 Java 发行版一致；失败时保留源码。

## 1. 定义数据协议

本例接受的 JSON 是 `{ type: 'bar' | 'line', labels: string[], values: number[] }`，两组数组等长，1–50 项，数值必须有限。文本限制 16,000 个 UTF-16 单元。`charts/data.ts` 拒绝未知字段，避免误以为支持任意 ECharts 配置。

~~~~markdown
~~~echarts
{"type":"line","labels":["Jan","Feb","Mar"],"values":[12,20,16]}
~~~
~~~~

这是可复制的最小业务协议，不是 MSFE 内置 ECharts 功能，也不是完整 `EChartsOption` 导出器。扩展数据字段时，同时扩展校验、图表映射和测试。不要把源码交给 `eval` / `new Function`；JSON 无法也不应该承载 formatter 函数。

## 2. 只识别已确认的完整围栏

`charts/extension.ts` 使用 `defineSource({ nodes: ['FencedCode'] })`，检查 `CodeInfo`、两个 `CodeMark` 与允许的父上下文，读取 `CodeText`。未闭合、未知语言或不符合范围的结构返回 `null`，继续显示源码。

为了使完整例子可审查，自定义 ECharts 示例仅替换顶层围栏；列表和引用中的 ECharts 保持源码。内置 Mermaid 有自己的容器处理。若扩展本例支持嵌套，必须处理列表标记、引用前缀、缩进和光标命中，而不能直接把 `match.from` 扩成整行就认为完成。

## 3. 通过块级投影提供视图

`addProjectionLayers()` 返回 `placement: 'block'`，再通过 `componentView(chartRenderer, props)` 描述非编辑预览。块级布局在 CodeMirror 计算视口之前提供，避免跨行替换引起高度错误。

保留稳定的 renderer 身份与 key，属性变化交给 `update()`。活动选区进入范围会显露源码；不要让图表 DOM 成为第二个编辑器。没有确认修改需求的预览 codec 可以令 `patch()` 返回 `null`。

## 4. 管理加载、尺寸和释放

`charts/renderer.ts` 将运行时 `scope` 和 `requestMeasure` 传入 Vue 视图。每次源码变化先取消旧请求，再在子作用域中加载 ECharts。卸载、换文档、取消后迟到的资源都必须释放。

容器先有实际尺寸，再调用 `echarts.init()`。`ResizeObserver` 负责容器尺寸变化后的 `resize()`；释放时断开观察器并 `dispose()`。本例固定图表高度，并在状态切换后通知 CodeMirror 测量。这些要求对应 [ECharts 容器尺寸与销毁文档](https://echarts.apache.org/handbook/en/concepts/chart-size/)。

加载失败展示说明、精确围栏源码和重试按钮；源码内容不因错误而丢失。只读下关闭编辑源码动作，但仍可查看和重试。

## 5. 接入 flowchart.js 或另一套引擎

复用本例的完整结构，但需要逐项替换：

1. 为扩展和投影层设置唯一名称，把允许语言改为 `flow`，别与 Mermaid 争抢同一围栏。
2. 将 `parseChartData()` 换成该 DSL 的校验及长度限制；不要把 DSL 当 JSON 解析。
3. 在浏览器生命周期中按需加载自己的引擎。flowchart.js 的公共流程是 `parse(source)` 后在有尺寸的目标容器上 `drawSVG(...)`，具体参数以 [官方项目文档](https://github.com/adrai/flowchart.js) 为准。
4. 将引擎实例、SVG、事件和可能的全局依赖封装进独立资源租约；更新先释放旧实例。不要假设另一引擎的取消、清理或 CSS 行为与 ECharts 一样。
5. 限制链接、HTML、外部资源和危险 SVG 输出；保留可复制源码回退，并补上缩放、主题及跨选区验证。

本页交互示例实际运行 Mermaid 与 ECharts；flowchart.js 是上述适配路径，不宣称已随 MSFE 安装或验证。复杂布局引擎也可移至 Worker，但仍需版本/取消校验和受控的 DOM 渲染出口。

## 验收清单

比较编辑前后的精确源码；覆盖有效/无效 JSON、快速连续改值、加载中换文档、离开视口、切换主题、窄容器、只读、复制、撤销及原生选区显露。不要只验证“出现一张图”。继续阅读 [资源生命周期](/extensions/resources) 和 [投影层](/extensions/projections)。
