# Custom code blocks

Customize toolbars, language loading and styles while retaining native code editing.

## Complete toolbar example

The example adds a language selector to a fence. Changing language edits only its header field, preserving the body and fence spelling. Locked mode disables the selector; undo restores the previous language.

### code-block/editor.vue

```vue
<script setup lang="ts">
import { StarterKit } from "@quirekit/msfe-starter-kit";
import { CodeBlock } from "@quirekit/msfe-code-block";
import { VueNodeViewRenderer } from "@quirekit/msfe-vue";
import EditorExample from "../shared/editor-example.vue";
import type { ExampleLocale } from "../shared/types";
import Toolbar from "./toolbar.vue";
import { codeLanguages } from "./languages";
const props = withDefaults(defineProps<{ /** Host UI language. */ locale?: ExampleLocale }>(), { locale: "zh-CN" });
const markdown = `${props.locale === "en" ? '# Code block workshop' : '# 代码块工作坊'}\n\n~~~typescript\nconst answer: number = 42\nconsole.log(answer)\n~~~\n`;
const CustomCodeBlock = CodeBlock.extend({
  /** @returns A header control; the code body remains in CodeMirror. */
  addNodeView() { return VueNodeViewRenderer(Toolbar); },
});
// Replace the preset's code-block entry, so there is still only one owner with this name.
const extensions = [StarterKit.configure({ codeBlock: CustomCodeBlock })];
</script>
<template><EditorExample :locale="locale" :markdown="markdown" :extensions="extensions" :code-languages="codeLanguages" /></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";

```

### code-block/toolbar.vue

```vue
<script setup lang="ts">
import { computed, inject } from "vue";
import type { MsfeNodeViewProps } from "@quirekit/msfe-core";
import type { CodeBlockAttributes } from "@quirekit/msfe-code-block";
import { QuireButton, QuireSelect } from "@quirekit/ui/vue";
import { exampleLocaleKey } from "../shared/locale";
const props = defineProps<{ /** Current header fields and guarded edits. */ nodeView: MsfeNodeViewProps<CodeBlockAttributes> }>();
const locale = inject(exampleLocaleKey, computed(() => "zh-CN"));
const english = computed(() => locale.value === "en");
const items = [{ value: "typescript", label: "TypeScript" }, { value: "javascript", label: "JavaScript" }, { value: "json", label: "JSON" }];
/** @param value - Selected exact language name. */
function change(value: string | null | undefined): void { if (value) props.nodeView.updateAttributes({ language: value }); }
</script>
<template>
  <span class="inline-flex items-center gap-2 py-2">
    <QuireSelect :model-value="nodeView.attrs.language" :items="items" :label="english ? 'Code language' : '代码语言'" size="sm" :disabled="nodeView.readOnly" @update:model-value="change" />
    <QuireButton size="sm" variant="ghost" @click="nodeView.revealSource()">{{ english ? 'Show source' : '显示源码' }}</QuireButton>
  </span>
</template>

```

### code-block/languages.ts

```ts
import type { CodeLanguage } from "@quirekit/msfe-core";
/** Exact opt-in names; unknown languages stay plain text and do not trigger guessed imports. */
export const codeLanguages: readonly CodeLanguage[] = [
  { name: "typescript", aliases: ["ts"],
    /** @returns TypeScript support, loaded only when encountered. */
    async load() { return (await import("@quirekit/cm-lang-javascript")).javascript({ typescript: true }); } },
  { name: "javascript", aliases: ["js"],
    /** @returns JavaScript support. */
    async load() { return (await import("@quirekit/cm-lang-javascript")).javascript(); } },
  { name: "json",
    /** @returns JSON support. */
    async load() { return (await import("@quirekit/cm-lang-json")).json(); } },
];

```

## Choose the customization layer

| Goal | Entry point |
| --- | --- |
| Fonts, surfaces and spacing | `EditorView.theme()` and syntax highlighting |
| Language, copy or menu controls | `CodeBlock.extend({ addNodeView() {} })` |
| TypeScript, JSON and other grammars | `codeLanguages` creation option |
| Mermaid, ECharts or flow DSL output | Semantic extension plus block projection; see [Diagrams](/en/extensions/diagrams) |

The CodeBlock NodeView sits before the fence with `placement: 'before'`. It does not replace the complete code body and provides no editable `contentDOM`.

## Replace one preset feature

~~~ts
import { StarterKit } from '@quirekit/msfe-starter-kit'
import { CodeBlock } from '@quirekit/msfe-code-block'
import { VueNodeViewRenderer } from '@quirekit/msfe-vue'
import Toolbar from './toolbar.vue'

const CustomCodeBlock = CodeBlock.extend({
  addNodeView() { return VueNodeViewRenderer(Toolbar) },
})
const extensions = [StarterKit.configure({ codeBlock: CustomCodeBlock })]
~~~

Do not install both StarterKit's original owner and a derived owner with the same name. Alternatively disable the preset entry and install exactly one replacement.

## Available fields and actions

The component receives `nodeView: MsfeNodeViewProps<CodeBlockAttributes>`. The persisted field is currently `language`. Call `nodeView.updateAttributes({ language: 'json' })`. Admitted language tokens contain at most 40 letters, digits, underscores or `+ # . -`; newlines and arbitrary header injection are rejected.

With an applicable selection, `editor.commands.setCodeLanguage('typescript')` updates a confirmed fence. Check failure results. Field validity does not imply that a matching highlighter was installed.

Copying is a read action: the host should read confirmed `CodeText` ranges while preserving indentation and line breaks. NodeView `getRange()` supplies coordinates, not an already-extracted code body. Use [anchors](/en/guide/selection) for actions spanning a dialog.

## Lazy language loading

`code-block/languages.ts` supplies TypeScript, JavaScript and JSON through `@quirekit/cm-lang-javascript` and `@quirekit/cm-lang-json`. Pass the registry as `codeLanguages` when creating the editor.

Names and aliases match exactly, case-insensitively. `load(signal)` returns `LanguageSupport`. Unknown languages remain plain text; no package path is guessed or downloaded. Dynamic imports cannot truly abort, so host resources must respect cancellation and discard obsolete results.

## Closed fences without a body line

`CodeBlock` shows an input placeholder when the opening and closing fence are on adjacent lines.
Clicking it or activating it with Enter / Space inserts one real line through a normal editor command,
preserves quote/list prefixes, and moves the caret into the body. Rendering, scrolling and mode changes
do not modify Markdown. Existing empty body lines need no placeholder. Read-only mode disables activation;
materializing the line supports undo and redo.

Set `CodeBlock.configure({ emptyLine: false })` to disable it; `emptyLineLabel` supplies the accessible hint.
MD Editor accepts `features.codeBlock: ProseCodeBlock.configure({ emptyLine: false })` and localizes the default hint.
Source mode shows the actual fences and newlines without an input placeholder.

MD Editor provides the lazy `mdCodeLanguages` catalog by default: TypeScript, JavaScript / JSX, TSX, JSON,
CSS, HTML, Python, Bash and Markdown. Supplying `codeLanguages` replaces the catalog; `[]` disables it.
Custom themes and grammar packages must share the same `@lezer/highlight` version: different tag identities
can leave parsed tokens without colors.

## Styling and active source

Style semantic classes such as `.msfe-code-line`, `.msfe-code-start`, `.msfe-code-body-start`, `.msfe-code-end` and `.msfe-code-indented`; use `HighlightStyle` for code tokens. Let the runtime handle source-token reveal and line layout. Do not hide arbitrary `.cm-line` elements or move the body into another editable region.

Selecting a fence reveals active source. Composition, cross-block drag selection, copying and history remain native. Report geometry changes through the component measurement entry; see [Projection layers](/en/extensions/projections).

## Verification

Cover language changes and undo, empty/unknown languages, long code, unclosed fences, both fence characters, readonly mode, Tab indentation and theme changes. See [Rendering API](/en/api/rendering) for exact language and component contracts.
