Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Comments
  • Discussion
  • Suggestions
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Details
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Drag & Drop
    • Navigation Feedback
    • Linting
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • DOCX
    • HTML
    • Markdown

Code Block

PreviousNext

Display code with syntax highlighting.

PlusCode Block Element
Loading…
CalloutColumn

On This Page

FeaturesKit usageInstallationAdd kitHigh-scale code blocksNative Plate DOMCodeMirrorAdapter customizationDiagramsPlantUML serverViewsInsert menuStatic and export outputManual usageInstallationAdd pluginsConfigure pluginsTurn into toolbar buttonInsert toolbar buttonPluginsCodeBlockPluginCodeHighlightPluginCommandsQueries
Build your editor
Production-ready AI template and reusable components.
Get all-access

Features

  • Syntax highlighting for code blocks
  • Support for multiple programming languages
  • Customizable language selection
  • Proper indentation handling
  • Markdown code fence shortcuts through CodeBlockRules.markdown({ on })
  • Typed shortcuts stay literal inside code blocks, and Markdown does not convert pasted plain text
Report an issue

Kit usage

Installation

The fastest way to add code blocks is with CodeBlockKit. It composes CodeBlockPlugin and the removable CodeHighlightPlugin with the shipped triple-backtick input rule and Plate UI component.

'use client';
 
import { DiffMatchPatch, DiffOp } from 'diff-match-patch-ts';
import { BracesIcon, Check, CheckIcon, CopyIcon } from 'lucide-react';
import { CodeBlockRules, type Element, ElementApi, NodeApi } from 'platejs';
import {
  CodeBlockPlugin,
  CodeHighlightPlugin,
  type Editor,
  EditorElement,
  type EditorElementProps,
  useEditor,
  useEditorReadOnly,
  useElement,
} from 'platejs/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
import { createCodeBlockLowlight } from '@/components/editor/code-block-lowlight';
import {
  FloatingPopover,
  FloatingPopoverContent,
  FloatingPopoverTrigger,
} from '@/components/editor/floating-popover';
 
const codeBlockLanguages: Array<{ label: string; value: string }> = [
  { label: 'Auto', value: 'auto' },
  { label: 'Plain Text', value: 'plaintext' },
  { label: 'ABAP', value: 'abap' },
  { label: 'Agda', value: 'agda' },
  { label: 'Arduino', value: 'arduino' },
  { label: 'ASCII Art', value: 'ascii' },
  { label: 'Assembly', value: 'x86asm' },
  { label: 'Bash', value: 'bash' },
  { label: 'BASIC', value: 'basic' },
  { label: 'BNF', value: 'bnf' },
  { label: 'C', value: 'c' },
  { label: 'C#', value: 'csharp' },
  { label: 'C++', value: 'cpp' },
  { label: 'Clojure', value: 'clojure' },
  { label: 'CoffeeScript', value: 'coffeescript' },
  { label: 'Coq', value: 'coq' },
  { label: 'CSS', value: 'css' },
  { label: 'Dart', value: 'dart' },
  { label: 'Dhall', value: 'dhall' },
  { label: 'Diff', value: 'diff' },
  { label: 'Docker', value: 'dockerfile' },
  { label: 'EBNF', value: 'ebnf' },
  { label: 'Elixir', value: 'elixir' },
  { label: 'Elm', value: 'elm' },
  { label: 'Erlang', value: 'erlang' },
  { label: 'F#', value: 'fsharp' },
  { label: 'Flow', value: 'flow' },
  { label: 'Fortran', value: 'fortran' },
  { label: 'Gherkin', value: 'gherkin' },
  { label: 'GLSL', value: 'glsl' },
  { label: 'Go', value: 'go' },
  { label: 'GraphQL', value: 'graphql' },
  { label: 'Groovy', value: 'groovy' },
  { label: 'Haskell', value: 'haskell' },
  { label: 'HCL', value: 'hcl' },
  { label: 'HTML', value: 'html' },
  { label: 'Idris', value: 'idris' },
  { label: 'Java', value: 'java' },
  { label: 'JavaScript', value: 'javascript' },
  { label: 'JSON', value: 'json' },
  { label: 'Julia', value: 'julia' },
  { label: 'Kotlin', value: 'kotlin' },
  { label: 'LaTeX', value: 'latex' },
  { label: 'Less', value: 'less' },
  { label: 'Lisp', value: 'lisp' },
  { label: 'LiveScript', value: 'livescript' },
  { label: 'LLVM IR', value: 'llvm' },
  { label: 'Lua', value: 'lua' },
  { label: 'Makefile', value: 'makefile' },
  { label: 'Markdown', value: 'markdown' },
  { label: 'Markup', value: 'markup' },
  { label: 'MATLAB', value: 'matlab' },
  { label: 'Mathematica', value: 'mathematica' },
  { label: 'Mermaid', value: 'mermaid' },
  { label: 'Nix', value: 'nix' },
  { label: 'Notion Formula', value: 'notion' },
  { label: 'Objective-C', value: 'objectivec' },
  { label: 'OCaml', value: 'ocaml' },
  { label: 'Pascal', value: 'pascal' },
  { label: 'Perl', value: 'perl' },
  { label: 'PHP', value: 'php' },
  { label: 'PowerShell', value: 'powershell' },
  { label: 'Prolog', value: 'prolog' },
  { label: 'Protocol Buffers', value: 'protobuf' },
  { label: 'PureScript', value: 'purescript' },
  { label: 'Python', value: 'python' },
  { label: 'R', value: 'r' },
  { label: 'Racket', value: 'racket' },
  { label: 'Reason', value: 'reasonml' },
  { label: 'Ruby', value: 'ruby' },
  { label: 'Rust', value: 'rust' },
  { label: 'Sass', value: 'scss' },
  { label: 'Scala', value: 'scala' },
  { label: 'Scheme', value: 'scheme' },
  { label: 'SCSS', value: 'scss' },
  { label: 'Shell', value: 'shell' },
  { label: 'Smalltalk', value: 'smalltalk' },
  { label: 'Solidity', value: 'solidity' },
  { label: 'SQL', value: 'sql' },
  { label: 'Swift', value: 'swift' },
  { label: 'TOML', value: 'toml' },
  { label: 'TypeScript', value: 'typescript' },
  { label: 'VB.Net', value: 'vbnet' },
  { label: 'Verilog', value: 'verilog' },
  { label: 'VHDL', value: 'vhdl' },
  { label: 'Visual Basic', value: 'vbnet' },
  { label: 'WebAssembly', value: 'wasm' },
  { label: 'XML', value: 'xml' },
  { label: 'YAML', value: 'yaml' },
];
 
type CodeBlockLanguageOption = Readonly<{ label: string; value: string }>;
 
/** What a wrapper such as `withDiagrams` adds to either code-block variant. */
export type CodeBlockAddon = Readonly<{
  languageOptions?: readonly CodeBlockLanguageOption[];
  toolbar?: React.ReactNode;
  wrapSource?: (source: React.ReactNode) => React.ReactNode;
}>;
 
export type CodeBlockElementProps = EditorElementProps<
  typeof CodeBlockPlugin
> & {
  addon?: CodeBlockAddon;
  showLanguageLabel?: boolean;
};
 
/** What `createCodeBlockPlugin` takes: `CodeBlockElement`, `CodeBlockCodeMirrorElement` or a wrapped one. */
export type CodeBlockComponent = (
  props: CodeBlockElementProps
) => React.ReactElement;
 
function getCodeBlockLanguageLabel(
  lang: string | null | undefined,
  addedOptions: readonly CodeBlockLanguageOption[] = []
) {
  const value = lang?.trim();
 
  if (!value) return null;
 
  return (
    [...codeBlockLanguages, ...addedOptions].find(
      (language) => language.value === value
    )?.label ?? value
  );
}
 
export function CodeBlockElement({
  addon,
  showLanguageLabel = true,
  ...props
}: CodeBlockElementProps) {
  return (
    <CodeBlockContainer
      addon={addon}
      elementProps={props}
      showLanguageLabel={showLanguageLabel}
    >
      <pre className="overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid">
        <code className="[&>[data-editor-node=text]]:contents">
          {props.children}
        </code>
      </pre>
    </CodeBlockContainer>
  );
}
 
export function CodeBlockContainer({
  addon,
  children,
  elementProps,
  languageOptions = codeBlockLanguages,
  showLanguageLabel = true,
}: {
  children: React.ReactNode;
  elementProps: EditorElementProps<typeof CodeBlockPlugin>;
  addon?: CodeBlockAddon;
  languageOptions?: readonly CodeBlockLanguageOption[];
  showLanguageLabel?: boolean;
}) {
  const { editor, element, attributes, plugin, ref, slots } = elementProps;
  const readOnly = useEditorReadOnly();
  const addedOptions = addon?.languageOptions;
  const options = React.useMemo(
    () =>
      addedOptions
        ? [
            ...languageOptions,
            ...addedOptions.filter(
              (added) =>
                !languageOptions.some((own) => own.value === added.value)
            ),
          ]
        : languageOptions,
    [addedOptions, languageOptions]
  );
 
  return (
    <EditorElement
      className="py-1 **:[.hljs-addition]:bg-[#f0fff4] **:[.hljs-addition]:text-[#22863a] dark:**:[.hljs-addition]:bg-[#3c5743] dark:**:[.hljs-addition]:text-[#ceead5] **:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#005cc5] dark:**:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#6596cf] **:[.hljs-built\\\\_in,.hljs-symbol]:text-[#e36209] dark:**:[.hljs-built\\\\_in,.hljs-symbol]:text-[#c3854e] **:[.hljs-bullet]:text-[#735c0f] **:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] dark:**:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] **:[.hljs-deletion]:bg-[#ffeef0] **:[.hljs-deletion]:text-[#b31d28] dark:**:[.hljs-deletion]:bg-[#473235] dark:**:[.hljs-deletion]:text-[#e7c7cb] **:[.hljs-emphasis]:italic **:[.hljs-keyword,.hljs-doctag,.hljs-temeditor-tag,.hljs-temeditor-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#d73a49] dark:**:[.hljs-keyword,.hljs-doctag,.hljs-temeditor-tag,.hljs-temeditor-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#ee6960] **:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#22863a] dark:**:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#36a84f] **:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#032f62] dark:**:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#3593ff] **:[.hljs-section]:font-bold **:[.hljs-section]:text-[#005cc5] dark:**:[.hljs-section]:text-[#61a5f2] **:[.hljs-strong]:font-bold **:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#6f42c1] dark:**:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#a77bfa]"
      attributes={attributes}
      element={element}
      plugin={plugin}
      ref={ref}
      slots={slots}
    >
      <div className="relative rounded-md bg-muted/50">
        {addon?.wrapSource ? addon.wrapSource(children) : children}
 
        <div
          className="absolute top-1 right-1 z-10 flex gap-0.5 select-none"
          contentEditable={false}
        >
          {addon?.toolbar}
 
          {!readOnly && element.language === 'json' && (
            <Button
              size="icon"
              variant="ghost"
              className="size-6 text-xs"
              onClick={() => formatJsonCodeBlock(editor, element)}
              title="Format code"
            >
              <BracesIcon className="!size-3.5 text-muted-foreground" />
            </Button>
          )}
 
          <CodeBlockCombobox
            addedOptions={addedOptions}
            languageOptions={options}
            showLanguageLabel={showLanguageLabel}
          />
 
          <CodeBlockCopyButton />
        </div>
      </div>
    </EditorElement>
  );
}
 
export function formatJsonCodeBlock(editor: Editor, element: Element) {
  if (editor.read.view.isReadOnly()) return;
 
  const blockPath = editor.read.nodes.path(element);
 
  if (!blockPath) return;
 
  const block = editor.read.nodes.get(blockPath)?.[0];
 
  if (
    block !== element ||
    !ElementApi.isElement(block) ||
    block.type !== editor.plugin(CodeBlockPlugin).schema.type ||
    block.language !== 'json' ||
    block.children.length !== 1
  ) {
    return;
  }
 
  const [child] = block.children;
 
  if (!NodeApi.isText(child)) return;
 
  const { text } = child;
  let formatted: string;
 
  try {
    formatted = JSON.stringify(JSON.parse(text), null, 2);
  } catch {
    return;
  }
 
  if (formatted === text) return;
 
  const diff = new DiffMatchPatch().diff_main(text, formatted, false);
  const edits: Array<{ end: number; start: number; text: string }> = [];
  let oldOffset = 0;
  let start: number | null = null;
  let replacement = '';
 
  for (const [operation, value] of diff) {
    if (operation === DiffOp.Equal) {
      if (start !== null) {
        edits.push({ start, end: oldOffset, text: replacement });
        start = null;
        replacement = '';
      }
      oldOffset += value.length;
    } else if (operation === DiffOp.Delete) {
      start ??= oldOffset;
      oldOffset += value.length;
    } else {
      start ??= oldOffset;
      replacement += value;
    }
  }
 
  if (start !== null) {
    edits.push({ start, end: oldOffset, text: replacement });
  }
 
  editor.update((tx) => {
    for (const edit of edits.reverse()) {
      const at = {
        anchor: { path: [...blockPath, 0], offset: edit.start },
        focus: { path: [...blockPath, 0], offset: edit.end },
      };
 
      if (edit.text) {
        tx.text.insert(edit.text, { at });
      } else {
        tx.text.delete({ at });
      }
    }
  });
}
 
function CodeBlockCopyButton() {
  const editor = useEditor();
  const element = useElement(CodeBlockPlugin);
 
  return (
    <CopyButton
      size="icon"
      variant="ghost"
      className="size-6 gap-1 text-xs text-muted-foreground"
      value={() => {
        const path = editor.read.nodes.path(element);
 
        return path ? editor.read.text.string(path) : '';
      }}
    />
  );
}
 
function CodeBlockCombobox({
  addedOptions,
  languageOptions,
  showLanguageLabel,
}: {
  languageOptions: readonly CodeBlockLanguageOption[];
  showLanguageLabel: boolean;
  addedOptions?: readonly CodeBlockLanguageOption[];
}) {
  const [open, setOpen] = React.useState(false);
  const readOnly = useEditorReadOnly();
  const editor = useEditor();
  const element = useElement(CodeBlockPlugin);
  const value = element.language || 'plaintext';
  const [searchValue, setSearchValue] = React.useState('');
 
  const items = React.useMemo(
    () =>
      languageOptions.filter(
        (language) =>
          !searchValue ||
          language.label.toLowerCase().includes(searchValue.toLowerCase())
      ),
    [searchValue, languageOptions]
  );
 
  if (readOnly) {
    if (!showLanguageLabel) return null;
 
    return (
      <CodeBlockLanguageLabel
        addedOptions={addedOptions}
        lang={element.language}
      />
    );
  }
 
  return (
    <FloatingPopover open={open} onOpenChange={setOpen}>
      <FloatingPopoverTrigger>
        <Button
          size="sm"
          variant="ghost"
          className="h-6 justify-between gap-1 px-2 text-xs text-muted-foreground select-none"
          aria-controls="code-block-language-options"
          aria-expanded={open}
          role="combobox"
        >
          {getCodeBlockLanguageLabel(value, addedOptions) ?? 'Plain Text'}
        </Button>
      </FloatingPopoverTrigger>
      <FloatingPopoverContent
        className="w-[200px] p-0"
        id="code-block-language-options"
        onFinalFocus={() => {
          setSearchValue('');
        }}
      >
        <Command shouldFilter={false}>
          <CommandInput
            className="h-9"
            value={searchValue}
            onValueChange={(innerValue) => {
              setSearchValue(innerValue);
            }}
            placeholder="Search language..."
          />
          <CommandEmpty>No language found.</CommandEmpty>
 
          <CommandList className="h-[344px] overflow-y-auto">
            <CommandGroup>
              {items.map((language) => (
                <CommandItem
                  key={language.label}
                  className="cursor-pointer"
                  value={language.value}
                  onSelect={(innerValue2) => {
                    const path = editor.read.nodes.path(element);
 
                    if (!path) return;
 
                    editor
                      .plugin(CodeBlockPlugin)
                      .update.set({ language: innerValue2 }, { at: path });
                    setSearchValue(innerValue2);
                    setOpen(false);
                  }}
                >
                  <Check
                    className={cn(
                      value === language.value ? 'opacity-100' : 'opacity-0'
                    )}
                  />
                  {language.label}
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </FloatingPopoverContent>
    </FloatingPopover>
  );
}
 
function CodeBlockLanguageLabel({
  addedOptions,
  lang,
}: {
  addedOptions?: readonly CodeBlockLanguageOption[];
  lang?: string | null;
}) {
  const label = getCodeBlockLanguageLabel(lang, addedOptions);
 
  if (!label) return null;
 
  return (
    <span className="flex h-6 items-center px-2 text-xs text-muted-foreground select-none">
      {label}
    </span>
  );
}
 
function CopyButton({
  value,
  ...props
}: { value: (() => string) | string } & Omit<
  React.ComponentProps<typeof Button>,
  'value'
>) {
  const [status, setStatus] = React.useState<
    'idle' | 'copying' | 'copied' | 'failed'
  >('idle');
 
  React.useEffect(() => {
    if (status !== 'copied') return undefined;
 
    const timeout = setTimeout(() => {
      setStatus('idle');
    }, 2000);
 
    return () => {
      clearTimeout(timeout);
    };
  }, [status]);
 
  return (
    <Button
      disabled={status === 'copying'}
      onClick={async () => {
        setStatus('copying');
        try {
          await navigator.clipboard.writeText(
            typeof value === 'function' ? value() : value
          );
          setStatus('copied');
        } catch {
          setStatus('failed');
        }
      }}
      title={status === 'failed' ? 'Copy failed. Try again.' : 'Copy'}
      {...props}
    >
      <span className="sr-only">
        {status === 'failed' ? 'Copy failed. Try again.' : 'Copy'}
      </span>
      {status === 'copied' ? (
        <CheckIcon className="!size-3" />
      ) : (
        <CopyIcon className="!size-3" />
      )}
    </Button>
  );
}
 
export const createCodeBlockPlugin = (component: CodeBlockComponent) =>
  CodeBlockPlugin.configure({
    component,
    inputRules: [CodeBlockRules.markdown({ on: 'match' })],
    shortcuts: { toggle: { keys: 'mod+alt+8' } },
  });
 
const lowlight = createCodeBlockLowlight();
 
export const CodeBlockKit = [
  createCodeBlockPlugin(CodeBlockElement),
  CodeHighlightPlugin.configure({
    initialState: { lowlight },
  }),
];
'use client';
 
import { DiffMatchPatch, DiffOp } from 'diff-match-patch-ts';
import { BracesIcon, Check, CheckIcon, CopyIcon } from 'lucide-react';
import { CodeBlockRules, type Element, ElementApi, NodeApi } from 'platejs';
import {
  CodeBlockPlugin,
  CodeHighlightPlugin,
  type Editor,
  EditorElement,
  type EditorElementProps,
  useEditor,
  useEditorReadOnly,
  useElement,
} from 'platejs/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';








































































































































































































































































































































































































































































































































  • CodeBlockElement: Renders code block containers.

Add kit

Add the kit to your plugins:

import { createEditor } from 'platejs/react';
import { CodeBlockKit } from '@/components/editor/code-block';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    ...CodeBlockKit,
  ],
});
import { createEditor } from 'platejs/react';
import { CodeBlockKit } from '@/components/editor/code-block';
 
const editor =




High-scale code blocks

CodeBlockKit keeps code in native editor DOM. Use CodeBlockCodeMirrorElement explicitly when one code block can contain enough text or syntax tokens to make that DOM expensive. Plate still owns the canonical text, selection, history, decorations, and collaboration state; CodeMirror owns the block's mounted DOM, input surface, and incremental syntax parser.

Native Plate DOM

This preview contains 1,000 lines. Open the 10,000-line stress test.

Loading…

CodeMirror

Loading…
'use client';
 
import { indentLess, indentMore, standardKeymap } from '@codemirror/commands';
import {
  HighlightStyle,
  indentUnit,
  LanguageDescription,
  syntaxHighlighting,
} from '@codemirror/language';
import { languages } from '@codemirror/language-data';
import { search, searchKeymap } from '@codemirror/search';
// oxlint-disable-next-line react-doctor/prefer-dynamic-import -- This opt-in component is the loading boundary; extensions must exist when its adapter mounts.
import { EditorState } from '@codemirror/state';
// oxlint-disable-next-line react-doctor/prefer-dynamic-import -- This opt-in component is the loading boundary; extensions must exist when its adapter mounts.
import { EditorView, keymap, runScopeHandlers } from '@codemirror/view';
import { tags } from '@lezer/highlight';
import { createCodeMirrorAdapter } from 'platejs/code-block/codemirror';
 
import {
  CodeBlockContainer,
  type CodeBlockElementProps,
} from '@/components/editor/code-block';
 
const syntaxColors = syntaxHighlighting(
  HighlightStyle.define([
    { tag: tags.keyword, class: 'hljs-keyword' },
    { tag: [tags.string, tags.regexp], class: 'hljs-string' },
    { tag: [tags.number, tags.bool, tags.null], class: 'hljs-number' },
    { tag: tags.comment, class: 'hljs-comment' },
    { tag: tags.function(tags.variableName), class: 'hljs-title function_' },
    { tag: [tags.typeName, tags.className], class: 'hljs-type' },
    { tag: tags.propertyName, class: 'hljs-attr' },
  ])
);
const languageDescription = (name?: string) =>
  name ? LanguageDescription.matchLanguageName(languages, name, false) : null;
 
const codeMirrorLanguages = [
  { label: 'Plain Text', value: 'plaintext' },
  ...languages.map(({ name }) => ({ label: name, value: name.toLowerCase() })),
];
 
const codeMirrorAdapter = createCodeMirrorAdapter({
  extensions: [
    syntaxColors,
    indentUnit.of('  '),
    EditorState.tabSize.of(2),
    search({ top: true }),
    keymap.of(searchKeymap),
    keymap.of(
      [
        { key: 'Tab', run: indentMore, shift: indentLess },
        ...standardKeymap,
      ].map((binding) => ({ ...binding, scope: 'code-block' }))
    ),
    EditorView.domEventHandlers({
      keydown(event, view) {
        // Composition starts before CodeMirror observes its first text mutation.
        if (event.isComposing || view.compositionStarted) return false;
        return runScopeHandlers(view, event, 'code-block');
      },
    }),
    EditorView.theme({
      '&': {
        backgroundColor: 'transparent',
        color: 'inherit',
      },
      '&.cm-focused': { outline: 'none' },
      '.cm-content': {
        caretColor: 'currentColor',
        fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
        fontSize: '0.875rem',
        lineHeight: 'normal',
        padding: '2rem 1rem 2rem 2rem',
      },
      '.cm-scroller': {
        maxHeight: 'min(70vh, 48rem)',
        overflow: 'auto',
      },
      '[data-code-block-model-selection]': {
        backgroundColor: 'color-mix(in srgb, var(--brand) 25%, transparent)',
      },
      '.cm-search': {
        alignItems: 'center',
        backgroundColor: 'var(--background)',
        borderBottom: '1px solid var(--border)',
        display: 'flex',
        flexWrap: 'wrap',
        gap: '0.25rem',
        padding: '0.375rem',
      },
      '.cm-search input': {
        backgroundColor: 'var(--background)',
        border: '1px solid var(--input)',
        borderRadius: '0.375rem',
        color: 'inherit',
        padding: '0.25rem 0.5rem',
      },
      '.cm-search button': {
        border: '1px solid var(--border)',
        borderRadius: '0.375rem',
        padding: '0.25rem 0.5rem',
      },
      '@media print': {
        '.cm-scroller': {
          maxHeight: 'none',
          overflow: 'visible',
        },
      },
    }),
  ],
  loadLanguage(name) {
    const description = languageDescription(name);
 
    return description?.support ?? description?.load() ?? [];
  },
});
 
export function CodeBlockCodeMirrorElement({
  addon,
  showLanguageLabel = true,
  ...props
}: CodeBlockElementProps) {
  return (
    <CodeBlockContainer
      addon={addon}
      elementProps={props}
      languageOptions={codeMirrorLanguages}
      showLanguageLabel={showLanguageLabel}
    >
      {props.slots.externalText({
        adapter: codeMirrorAdapter,
        ariaLabel: 'Code block',
        config: { language: props.element.language },
      })}
    </CodeBlockContainer>
  );
}
'use client';
 
import { indentLess, indentMore, standardKeymap } from '@codemirror/commands';
import {
  HighlightStyle,
  indentUnit,
  LanguageDescription,
  syntaxHighlighting,
} from '@codemirror/language';
import { languages } from '@codemirror/language-data';
import { search, searchKeymap } from '@codemirror/search';
// oxlint-disable-next-line react-doctor/prefer-dynamic-import -- This opt-in component is the loading boundary; extensions must exist when its adapter mounts.
import { EditorState } from '@codemirror/state';
// oxlint-disable-next-line react-doctor/prefer-dynamic-import -- This opt-in component is the loading boundary; extensions must exist when its adapter mounts.
import { EditorView, keymap, runScopeHandlers } from '@codemirror/view';
import { tags } from

























































































































For an editor whose code blocks all use CodeMirror, replace the component and omit the Lowlight plugin:

import { CodeBlockPlugin, CodeHighlightPlugin } from 'platejs/react';
import {
  CodeBlockCodeMirrorElement,
} from '@/components/editor/code-block-codemirror';
import { createCodeBlockPlugin } from '@/components/editor/code-block';
import { EditorKit } from '@/components/editor/plugins';
 
const codeBlockCodeMirrorPlugin = createCodeBlockPlugin(
  CodeBlockCodeMirrorElement
);
 
export const LargeCodeEditorKit = EditorKit
  .filter((plugin) => plugin.name !== CodeHighlightPlugin.name)
  .map((plugin) =>
    plugin.name === CodeBlockPlugin.name ? codeBlockCodeMirrorPlugin : plugin
  );
import { CodeBlockPlugin, CodeHighlightPlugin } from 'platejs/react';
import {
  CodeBlockCodeMirrorElement,
} from '@/components/editor/code-block-codemirror';
import { createCodeBlockPlugin } from '@/components/editor/code-block';
import { EditorKit } from '@/components/editor/plugins';
 
const codeBlockCodeMirrorPlugin = createCodeBlockPlugin(
  CodeBlockCodeMirrorElement
);
 
export const LargeCodeEditorKit = EditorKit
  .filter((plugin) => plugin.name !== CodeHighlightPlugin.name)
  .map((plugin) =>
    plugin.name ===

Choose an explicit language from the CodeMirror picker. Parsers load on demand; plain text and unrecognized language values stay unhighlighted. CodeMirror keeps neutral range decorations, including overlapping annotations.

For native and CodeMirror views of the same editor, retain CodeHighlightPlugin for the native view. Its syntax decorations carry data-code-block-syntax; CodeMirror excludes those decorations and paints its own syntax. Native views retain the full code DOM.

Loading…

This choice is never automatic. Do not switch renderers at a character threshold, and do not combine a block adapter with whole-document virtualization.

CodeMirror search (Mod+F) finds offscreen code. Browser page Find can inspect only the mounted viewport DOM. The copy button and print layout use the full canonical code; printing temporarily renders every line.

Adapter customization

createCodeMirrorAdapter from platejs/code-block/codemirror owns the ExternalText projection. Create it once outside the component and pass it to props.slots.externalText with config: { language }.

import { EditorView } from '@codemirror/view';
import { createCodeMirrorAdapter } from 'platejs/code-block/codemirror';
 
const adapter = createCodeMirrorAdapter({
  extensions: [EditorView.lineWrapping],
});
import { EditorView } from '@codemirror/view';
import { createCodeMirrorAdapter } from 'platejs/code-block/codemirror';
 
const adapter = createCodeMirrorAdapter({
  extensions: [EditorView.lineWrapping],
});

The optional entrypoint requires @codemirror/language, @codemirror/state and @codemirror/view. extensions configures presentation, search and native editing commands. loadLanguage(language) returns a CodeMirror extension or a promise for one; obsolete loads are ignored after configuration changes or disposal. Omit it for plain text. Keep CodeMirror history extensions disabled: undo and redo use the editor's shared history.

Plate-authored text, selection, configuration, and decoration transactions bypass CodeMirror transaction filters so local extensions cannot veto canonical state. Update listeners observe a local prediction before any synchronous canonical correction. They must not synchronously dispatch another CodeMirror transaction or mutate Plate; use a transaction filter to shape the current transaction, and run later edits from an input handler or command after the listener returns.

The copied component uses two-space line indentation for Tab and syntax-aware indentation for Enter. Its language picker, search panel and theme stay in the component.

Diagrams

A code block in a diagram language can draw its picture below the source.

Loading…

Add the diagram wrapper and one renderer item for each engine you draw:

pnpm dlx shadcn@latest add @plate/code-block-diagram @plate/code-block-diagram-mermaid @plate/code-block-diagram-graphviz
pnpm dlx shadcn@latest add @plate/code-block-diagram @plate/code-block-diagram-mermaid @plate/code-block-diagram-graphviz
'use client';
 
import { DownloadIcon } from 'lucide-react';
import { NodeApi } from 'platejs';
import { useEditor, useEditorSelector, usePath } from 'platejs/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type {
  CodeBlockAddon,
  CodeBlockComponent,
  CodeBlockElementProps,
} from '@/components/editor/code-block';
 
export type DiagramRenderer = Readonly<{
  /** Fence languages this engine draws, canonical first, such as ['graphviz', 'dot']. */
  languages: readonly [string, ...string[]];
  label: string;
  /**
   * Resolves SVG markup, or rejects with a message the block shows. Once
   * `signal` aborts, the block drops the result, so a renderer may stop early
   * but does not have to.
   */
  render: (source: string, options: { signal: AbortSignal }) => Promise<string>;
}>;
 
type DiagramView = 'both' | 'code';
 
const diagramViews: ReadonlyArray<{ label: string; value: DiagramView }> = [
  { label: 'Code', value: 'code' },
  { label: 'Both', value: 'both' },
];
 
type DiagramPicture = Readonly<{ error: string | null; url: string | null }>;
 
const emptyPicture: DiagramPicture = { error: null, url: null };
 
/** Draw the code blocks whose language one of `renderers` claims. */
export function withDiagrams(
  Element: CodeBlockComponent,
  { renderers }: { renderers: readonly DiagramRenderer[] }
): CodeBlockComponent {
  const rendererByLanguage = new Map<string, DiagramRenderer>();
 
  for (const renderer of renderers) {
    for (const language of renderer.languages) {
      if (rendererByLanguage.has(language)) {
        throw new Error(`Two diagram renderers claim "${language}".`);
      }
 
      rendererByLanguage.set(language, renderer);
    }
  }
 
  const languageOptions = renderers.map((renderer) => ({
    label: renderer.label,
    value: renderer.languages[0],
  }));
 
  return function DiagramCodeBlockElement(props: CodeBlockElementProps) {
    const renderer = rendererByLanguage.get(props.element.language ?? '');
    const outerOptions = props.addon?.languageOptions;
    const addon = {
      ...props.addon,
      languageOptions: outerOptions
        ? [...outerOptions, ...languageOptions]
        : languageOptions,
    };
 
    if (!renderer) return <Element {...props} addon={addon} />;
 
    return (
      <DiagramCodeBlock
        addon={addon}
        Element={Element}
        elementProps={props}
        renderer={renderer}
      />
    );
  };
}
 
function DiagramCodeBlock({
  addon,
  Element,
  elementProps,
  renderer,
}: {
  addon: CodeBlockAddon;
  Element: CodeBlockComponent;
  elementProps: CodeBlockElementProps;
  renderer: DiagramRenderer;
}) {
  const { element } = elementProps;
  const containerRef = React.useRef<HTMLDivElement>(null);
  const [view, setView] = React.useState<DiagramView>('both');
  const nearViewport = useNearViewport(containerRef);
  const source = useLiveSource(element);
  const picture = useDiagramPicture(
    renderer,
    source,
    view === 'both' && nearViewport
  );
 
  return (
    <Element
      {...elementProps}
      addon={{
        ...addon,
        toolbar: (
          <>
            {addon.toolbar}
            <DiagramViewToggle view={view} onPick={setView} />
            {picture.url && <DiagramExportButton url={picture.url} />}
          </>
        ),
        wrapSource: (body) => (
          <div ref={containerRef}>
            {addon.wrapSource ? addon.wrapSource(body) : body}
            {/* The editor restores child nodes added or removed beside the
                source, so the picture area stays mounted and only hides. */}
            <div
              className="border-t border-border/60 px-8 py-4"
              contentEditable={false}
              hidden={view === 'code'}
            >
              {view === 'both' &&
                picture.url && (
                  // oxlint-disable-next-line nextjs/no-img-element -- [P1 local-invariant] toDataUrl turns every rendered SVG into a data URL; optimization and remote loading do not apply.
                  <img
                    alt={element.language ?? renderer.label}
                    className="mx-auto max-h-96 max-w-full"
                    draggable={false}
                    src={picture.url}
                  />
                )}
              {view === 'both' && (
                <DiagramStatus
                  empty={source.trim().length === 0}
                  error={picture.error}
                  loading={!picture.url && !picture.error}
                />
              )}
            </div>
          </div>
        ),
      }}
    />
  );
}
 
function DiagramViewToggle({
  onPick,
  view,
}: {
  view: DiagramView;
  onPick: (view: DiagramView) => void;
}) {
  return (
    <div className="flex items-center rounded-md" role="group">
      {diagramViews.map((option) => (
        <Button
          key={option.value}
          size="sm"
          variant="ghost"
          className={cn(
            'h-6 px-2 text-xs text-muted-foreground',
            view === option.value && 'bg-accent text-accent-foreground'
          )}
          aria-pressed={view === option.value}
          onClick={() => onPick(option.value)}
          onMouseDown={(event) => event.preventDefault()}
        >
          {option.label}
        </Button>
      ))}
    </div>
  );
}
 
function DiagramExportButton({ url }: { url: string }) {
  return (
    <Button
      size="icon"
      variant="ghost"
      className="size-6 text-muted-foreground"
      onClick={(event) => {
        const { ownerDocument } = event.currentTarget;
        const image = ownerDocument.createElement('img');
 
        image.addEventListener(
          'load',
          () => {
            const { naturalHeight, naturalWidth } = image;
            // A canvas past the browser's size limit exports as `data:,`. These
            // bounds sit within every engine's, iOS Safari's 16,777,216 px included.
            const scale = Math.min(
              1,
              16_384 / Math.max(naturalWidth, naturalHeight),
              Math.sqrt(16_777_216 / (naturalWidth * naturalHeight))
            );
            const canvas = ownerDocument.createElement('canvas');
            canvas.width = Math.max(1, Math.floor(naturalWidth * scale));
            canvas.height = Math.max(1, Math.floor(naturalHeight * scale));
            canvas
              .getContext('2d')
              ?.drawImage(image, 0, 0, canvas.width, canvas.height);
 
            const link = ownerDocument.createElement('a');
            link.href = canvas.toDataURL('image/png');
            link.download = 'diagram.png';
            link.click();
          },
          { once: true }
        );
        image.src = url;
      }}
      onMouseDown={(event) => event.preventDefault()}
      title="Export PNG"
    >
      <DownloadIcon className="!size-3.5" />
    </Button>
  );
}
 
function DiagramStatus({
  empty,
  error,
  loading,
}: {
  empty: boolean;
  error: string | null;
  loading: boolean;
}) {
  if (empty) {
    return (
      <span className="block text-sm text-muted-foreground">Empty diagram</span>
    );
  }
  if (error) {
    return (
      <span className="block text-sm whitespace-pre-wrap text-destructive">
        {error}
      </span>
    );
  }
  if (loading) {
    return (
      <span className="block text-sm text-muted-foreground">Drawing…</span>
    );
  }
 
  return null;
}
 
function useLiveSource(element: CodeBlockElementProps['element']) {
  const editor = useEditor();
  const path = usePath();
  const nodeKey = editor.read((state) => state.key(path));
 
  return useEditorSelector(
    (current) => NodeApi.string(current.read.nodes.get(path)?.[0] ?? element),
    {
      shouldUpdate: (change) =>
        !change || !nodeKey || change.changed.hasNodeKey(nodeKey, 'node'),
    }
  );
}
 
function useNearViewport(ref: React.RefObject<HTMLElement | null>) {
  const [near, setNear] = React.useState(
    () => typeof IntersectionObserver === 'undefined'
  );
 
  React.useEffect(() => {
    const node = ref.current;
 
    if (!node || typeof IntersectionObserver === 'undefined') return undefined;
 
    const observer = new IntersectionObserver(
      (entries) => setNear(entries.at(-1)?.isIntersecting ?? false),
      { rootMargin: '200px 0px' }
    );
 
    observer.observe(node);
 
    return () => observer.disconnect();
  }, [ref]);
 
  return near;
}
 
const scheduleIdle = (callback: () => void) => {
  if (typeof requestIdleCallback === 'function') {
    const id = requestIdleCallback(callback);
 
    return () => cancelIdleCallback(id);
  }
 
  const id = setTimeout(callback, 0);
 
  return () => clearTimeout(id);
};
 
const toDataUrl = (svg: string) => {
  const root = new DOMParser().parseFromString(
    svg,
    'image/svg+xml'
  ).documentElement;
 
  if (root.localName !== 'svg') {
    throw new Error('The renderer did not return an SVG picture.');
  }
 
  let binary = '';
 
  for (const byte of new TextEncoder().encode(svg)) {
    binary += String.fromCharCode(byte);
  }
 
  return `data:image/svg+xml;base64,${btoa(binary)}`;
};
 
function useDiagramPicture(
  renderer: DiagramRenderer,
  source: string,
  enabled: boolean
): DiagramPicture {
  const [picture, setPicture] = React.useState<
    DiagramPicture & { drawnBy: DiagramRenderer | null }
  >({ ...emptyPicture, drawnBy: null });
  const singleFlight = React.useRef<{
    enabled: boolean;
    next: string | null;
    renderer: DiagramRenderer;
    requested: string | null;
    unsettled: AbortController | null;
  }>({ enabled, next: null, renderer, requested: null, unsettled: null });
  const empty = source.trim().length === 0;
 
  React.useEffect(() => {
    const { current } = singleFlight;
 
    return () => {
      if (current.unsettled) {
        current.unsettled.abort();
        current.requested = null;
      }
      current.next = null;
    };
  }, []);
 
  React.useEffect(() => {
    const { current } = singleFlight;
 
    const drain = () => {
      const { next, renderer: drawing } = current;
 
      if (current.unsettled || !current.enabled || next === null) return;
 
      const controller = new AbortController();
 
      current.next = null;
      current.requested = next;
      current.unsettled = controller;
      drawing
        .render(next, { signal: controller.signal })
        .then(toDataUrl)
        .then(
          (url) => {
            if (!controller.signal.aborted) {
              setPicture({ drawnBy: drawing, error: null, url });
            }
          },
          (error: unknown) => {
            if (!controller.signal.aborted) {
              setPicture((previous) => ({
                drawnBy: drawing,
                error: error instanceof Error ? error.message : String(error),
                url: previous.drawnBy === drawing ? previous.url : null,
              }));
            }
          }
        )
        .finally(() => {
          current.unsettled = null;
          scheduleIdle(drain);
        });
    };
 
    current.enabled = enabled;
    if (current.renderer !== renderer || empty) {
      current.unsettled?.abort();
      current.renderer = renderer;
      current.requested = null;
    }
    current.next =
      enabled && !empty && source !== current.requested ? source : null;
 
    return current.next === null ? undefined : scheduleIdle(drain);
  }, [empty, enabled, renderer, source]);
 
  return empty || picture.drawnBy !== renderer ? emptyPicture : picture;
}
'use client';
 
import { DownloadIcon } from 'lucide-react';
import { NodeApi } from 'platejs';
import { useEditor, useEditorSelector, usePath } from 'platejs/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type {
  CodeBlockAddon,
  CodeBlockComponent,
  CodeBlockElementProps,
} from '@/components/editor/code-block';
 
export type DiagramRenderer = Readonly<{




































































































































































































































































































































































































ItemRendererLanguagesPackages
code-block-diagram-mermaidmermaidRenderermermaidpele
code-block-diagram-graphvizgraphvizRenderergraphviz, dotviz.js, @types/viz.js
code-block-diagram-flowchartflowchartRendererflowchartflowchart.js
code-block-diagram-plantumlcreatePlantUmlRenderer({ server })plantuml, pumlplantuml-encoder, @types/plantuml-encoder

Wrap the code block component and replace the kit's code-block plugin:

components/editor/diagram-editor-kit.tsx
import { CodeBlockPlugin } from 'platejs/react';
 
import {
  CodeBlockElement,
  createCodeBlockPlugin,
} from '@/components/editor/code-block';
import { withDiagrams } from '@/components/editor/code-block-diagram';
import { graphvizRenderer } from '@/components/editor/code-block-diagram-graphviz';
import { mermaidRenderer } from '@/components/editor/code-block-diagram-mermaid';
import { EditorKit } from '@/components/editor/plugins';
 
const diagramCodeBlockPlugin = createCodeBlockPlugin(
  withDiagrams(CodeBlockElement, {
    renderers: [mermaidRenderer, graphvizRenderer],
  })
);
 
export const DiagramEditorKit = EditorKit.map((plugin) =>
  plugin.name === CodeBlockPlugin.name ? diagramCodeBlockPlugin : plugin
);
components/editor/diagram-editor-kit.tsx
import { CodeBlockPlugin } from 'platejs/react';
 
import {
  CodeBlockElement,
  createCodeBlockPlugin,
} from '@/components/editor/code-block';
import { withDiagrams } from '@/components/editor/code-block-diagram';
import { graphvizRenderer } from '@/components/editor/code-block-diagram-graphviz';
import { mermaidRenderer } from '@/components/editor/code-block-diagram-mermaid';
import { EditorKit } from '@/components/editor/plugins';
 
const diagramCodeBlockPlugin = createCodeBlockPlugin(
  withDiagrams(CodeBlockElement, {
    renderers: [mermaidRenderer, graphvizRenderer],
  })
);
 
export const

withDiagrams wraps CodeBlockCodeMirrorElement the same way. Each renderer adds its label to the language picker, and withDiagrams throws when two renderers claim one language. The Mermaid renderer draws with Pele, which reads Mermaid syntax and lays out and colors each diagram its own way. It draws the diagram types built into Mermaid, not ZenUML or other external types. Mermaid themes do not apply, whether set through mermaid.initialize or a %%{init}%% directive.

PlantUML server

PlantUML draws through a server your app runs. The renderer sends each block's source only to that server, as <server>/svg/<encoded source>, and fails instead of following a redirect.

import { createPlantUmlRenderer } from '@/components/editor/code-block-diagram-plantuml';
 
const plantUmlRenderer = createPlantUmlRenderer({
  server: 'https://plantuml.example.com',
});
import { createPlantUmlRenderer } from '@/components/editor/code-block-diagram-plantuml';
 
const plantUmlRenderer = createPlantUmlRenderer({
  server: 'https://plantuml.example.com',
});

Views

A diagram block's toolbar switches between Code, which shows only the source, and Both, which shows the picture below it. Both is the default in editable and read-only editors. The view lives in the component; the document stores only the code block's text and language. Export PNG saves the drawn picture as diagram.png.

A block draws once it is near the viewport and the browser is idle. Each block runs one render at a time, and only its latest source waits for the next one.

  • The picture is an image of the renderer's SVG, with the language as its alt text. Export PNG draws it at the size the SVG states, or at the browser's default size for an SVG that states none.
  • A Mermaid picture paints no background, so in a dark theme its dark lines sit directly on the page.
  • A failed render shows its error below the source, and output that is not SVG shows as an error too.
  • A language that no renderer claims stays an ordinary code block.
  • A diagram block deletes like any other block.

Insert menu

The shared insert and slash menus have no Diagram entry, because the default editor kit installs no renderer. An app that draws diagrams adds one to its copy of the insert menu:

import { BaseCodeBlockPlugin } from 'platejs';
 
const insertItem = {
  icon: <WorkflowIcon />,
  label: 'Diagram',
  value: 'diagram',
  onSelect: () => {
    const codeBlock = editor.plugin(BaseCodeBlockPlugin);
 
    if (!codeBlock.installed || editor.read.view.isReadOnly()) return;
 
    codeBlock.update.insert({ language: 'mermaid' }, { select: true });
  },
};
import { BaseCodeBlockPlugin } from 'platejs';
 
const insertItem = {
  icon: <WorkflowIcon />,
  label: 'Diagram',
  value: 'diagram',
  onSelect: () => {
    const codeBlock = editor.plugin(BaseCodeBlockPlugin);
 
    if (!codeBlock.installed || editor.read.view.isReadOnly()) return;
 
    codeBlock.update.insert({ language: 'mermaid' }, { select: true });
  },
};

Static and export output

Static rendering and Markdown export draw no picture. A diagram block keeps its source as an ordinary code block, and Markdown writes it as a code fence with its language, such as mermaid.

Manual usage

Installation

pnpm add platejs lowlight
pnpm add platejs lowlight

Add plugins

Add CodeBlockPlugin for code-block editing. Add CodeHighlightPlugin separately when you want Lowlight syntax highlighting.

import { CodeBlockPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    CodeBlockPlugin,
  ],
});
import { CodeBlockPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
 
const editor = createEditor({




Configure plugins

Configure each capability where it is owned. CodeBlockPlugin owns the multiline text and editing behavior. CodeHighlightPlugin owns highlighting.

Basic Setup with All Languages:

import { CodeBlockRules } from 'platejs';
import { CodeBlockPlugin, CodeHighlightPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
import { CodeBlockElement } from '@/components/editor/code-block';
import { createCodeBlockLowlight } from '@/components/editor/code-block-lowlight';
 
const lowlight = createCodeBlockLowlight();
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    CodeBlockPlugin.configure({
      component: CodeBlockElement,
      inputRules: [CodeBlockRules.markdown({ on: 'match' })],
      shortcuts: { toggle: { keys: 





Custom Language Setup (Optimized Bundle):

For optimized bundle size, you can register only specific languages:

import { createLowlight } from 'lowlight';
import css from 'highlight.js/lib/languages/css';
import js from 'highlight.js/lib/languages/javascript';
import ts from 'highlight.js/lib/languages/typescript';
import html from 'highlight.js/lib/languages/xml';
 
// Create a lowlight instance
const lowlight = createLowlight();
 
// Register only the languages you need
lowlight.register('html', html);
lowlight.register('css', css);
lowlight.register('js', js);
lowlight.register('ts', ts);
















  • CodeBlockPlugin.configure({ component }): Assigns CodeBlockElement to render code block containers.
  • inputRules: Registers the triple-backtick fence rule. Use on: 'match' to commit when the fence becomes complete or on: 'break' to commit on Enter.
  • CodeHighlightPlugin.initialState.lowlight: Lowlight instance for syntax highlighting. The plugin reads the supplied registry without modifying it. The copied createCodeBlockLowlight() resource registers the shipped browser-safe Python grammar and its py, gyp, and ipython aliases before the editor uses it.
  • CodeHighlightPlugin.initialState.defaultLanguage: Default language when no language is specified.
  • shortcuts.toggle: Defines a keyboard shortcut to toggle code blocks.

Each codeBlock contains one text child. Newline characters define physical lines; editing and highlighting derive their line behavior from text offsets.

Inside a code block no input rule runs, so # , **x**, -> and URLs stay as typed. Neither Markdown nor an input rule converts pasted plain text.

For plain code blocks, omit CodeHighlightPlugin:

import { CodeBlockPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
 
const editor = createEditor({
  plugins: [CodeBlockPlugin],
});
import { CodeBlockPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
 
const editor = createEditor({
  plugins: [CodeBlockPlugin],
});

For the runtime model, see Plugin Input Rules.

Turn into toolbar button

Add a structural action that calls BaseCodeBlockPlugin directly. Enable it only for a contiguous sibling selection with one homogeneous active state.

import { BaseCodeBlockPlugin, PLUGINS } from 'platejs';
 
const turnIntoItem = {
  icon: <FileCodeIcon />,
  label: 'Code',
  value: PLUGINS.codeBlock,
  onSelect: () => {
    const codeBlock = editor.plugin(BaseCodeBlockPlugin);
 
    if (!codeBlock.installed || editor.read.view.isReadOnly()) return;
 
    codeBlock.update.toggle();
  },
};

Insert toolbar button

Add an item whose callback calls the installed code-block feature:

import { BaseCodeBlockPlugin, PLUGINS } from 'platejs';
 
const insertItem = {
  icon: <FileCodeIcon />,
  label: 'Code',
  value: PLUGINS.codeBlock,
  onSelect: () => {
    const codeBlock = editor.plugin(BaseCodeBlockPlugin);
 
    if (!codeBlock.installed || editor.read.view.isReadOnly()) return;
 
    codeBlock.update.insert({}, { select: true });
  },
};

Plugins

CodeBlockPlugin

Owns the code-block schema, multiline editing behavior, input rules, shortcuts, and commands.

CodeHighlightPlugin

Produces transient syntax decorations with Lowlight. Syntax tokens are not stored as text marks. Include or omit this descriptor as one ordinary plugin-array entry.

Options

    Default language to use when no language is specified. Set to null to disable syntax highlighting by default.

    Lowlight instance to use for highlighting. If not provided, syntax highlighting will be disabled.

Commands

Install CodeBlockPlugin, then mutate code blocks through its command group.

editor.update.codeBlock.insert();
editor.update.codeBlock.toggle();
editor.update.codeBlock.tab();
editor.update.codeBlock.untab();
editor.update.codeBlock.resetBlock();
editor.update.codeBlock.selectAll();
editor.update.codeBlock.insert();
editor.update.codeBlock.toggle();
editor.update.codeBlock.tab();
editor.update.codeBlock.untab();
editor.update.codeBlock.resetBlock();
editor.update.codeBlock.selectAll();
  • insert() converts an empty selected block in place. For non-empty or expanded selections, it inserts and selects a paragraph after the range-end block, then converts that paragraph. It is a no-op without a selection.
  • toggle() converts selected blocks to or from code blocks and returns whether it changed the document. On a Details title it converts the title alone and leaves the body after the code block. It returns false when the selection holds the title and more blocks, and when the selection is in a named root that the editor or view running it does not edit.
  • tab() and untab() indent or outdent selected physical lines by two spaces.
  • resetBlock() unwraps the selected code block into paragraphs.
  • selectAll() selects the containing code block.

Queries

Read code-block state through the installed descriptor:

const codeBlock = editor.plugin(CodeBlockPlugin);
 
codeBlock.read.entry();
codeBlock.read.isEmpty();
codeBlock.read.indentDepth();
const codeBlock = editor.plugin(CodeBlockPlugin);
 
codeBlock.read.entry();
codeBlock.read.isEmpty();
codeBlock.read.indentDepth();
  • entry({ at? }) returns { codeBlock: [element, path] } for the containing code block, or undefined. It uses the current selection when at is omitted.
  • isEmpty() returns whether the containing code block has no text. It returns false outside a code block.
  • indentDepth() returns the number of leading whitespace characters on the anchor's physical line, or 0 outside a code block.
import
{
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
import { createCodeBlockLowlight } from '@/components/editor/code-block-lowlight';
import {
FloatingPopover,
FloatingPopoverContent,
FloatingPopoverTrigger,
} from '@/components/editor/floating-popover';
const codeBlockLanguages: Array<{ label: string; value: string }> = [
{ label: 'Auto', value: 'auto' },
{ label: 'Plain Text', value: 'plaintext' },
{ label: 'ABAP', value: 'abap' },
{ label: 'Agda', value: 'agda' },
{ label: 'Arduino', value: 'arduino' },
{ label: 'ASCII Art', value: 'ascii' },
{ label: 'Assembly', value: 'x86asm' },
{ label: 'Bash', value: 'bash' },
{ label: 'BASIC', value: 'basic' },
{ label: 'BNF', value: 'bnf' },
{ label: 'C', value: 'c' },
{ label: 'C#', value: 'csharp' },
{ label: 'C++', value: 'cpp' },
{ label: 'Clojure', value: 'clojure' },
{ label: 'CoffeeScript', value: 'coffeescript' },
{ label: 'Coq', value: 'coq' },
{ label: 'CSS', value: 'css' },
{ label: 'Dart', value: 'dart' },
{ label: 'Dhall', value: 'dhall' },
{ label: 'Diff', value: 'diff' },
{ label: 'Docker', value: 'dockerfile' },
{ label: 'EBNF', value: 'ebnf' },
{ label: 'Elixir', value: 'elixir' },
{ label: 'Elm', value: 'elm' },
{ label: 'Erlang', value: 'erlang' },
{ label: 'F#', value: 'fsharp' },
{ label: 'Flow', value: 'flow' },
{ label: 'Fortran', value: 'fortran' },
{ label: 'Gherkin', value: 'gherkin' },
{ label: 'GLSL', value: 'glsl' },
{ label: 'Go', value: 'go' },
{ label: 'GraphQL', value: 'graphql' },
{ label: 'Groovy', value: 'groovy' },
{ label: 'Haskell', value: 'haskell' },
{ label: 'HCL', value: 'hcl' },
{ label: 'HTML', value: 'html' },
{ label: 'Idris', value: 'idris' },
{ label: 'Java', value: 'java' },
{ label: 'JavaScript', value: 'javascript' },
{ label: 'JSON', value: 'json' },
{ label: 'Julia', value: 'julia' },
{ label: 'Kotlin', value: 'kotlin' },
{ label: 'LaTeX', value: 'latex' },
{ label: 'Less', value: 'less' },
{ label: 'Lisp', value: 'lisp' },
{ label: 'LiveScript', value: 'livescript' },
{ label: 'LLVM IR', value: 'llvm' },
{ label: 'Lua', value: 'lua' },
{ label: 'Makefile', value: 'makefile' },
{ label: 'Markdown', value: 'markdown' },
{ label: 'Markup', value: 'markup' },
{ label: 'MATLAB', value: 'matlab' },
{ label: 'Mathematica', value: 'mathematica' },
{ label: 'Mermaid', value: 'mermaid' },
{ label: 'Nix', value: 'nix' },
{ label: 'Notion Formula', value: 'notion' },
{ label: 'Objective-C', value: 'objectivec' },
{ label: 'OCaml', value: 'ocaml' },
{ label: 'Pascal', value: 'pascal' },
{ label: 'Perl', value: 'perl' },
{ label: 'PHP', value: 'php' },
{ label: 'PowerShell', value: 'powershell' },
{ label: 'Prolog', value: 'prolog' },
{ label: 'Protocol Buffers', value: 'protobuf' },
{ label: 'PureScript', value: 'purescript' },
{ label: 'Python', value: 'python' },
{ label: 'R', value: 'r' },
{ label: 'Racket', value: 'racket' },
{ label: 'Reason', value: 'reasonml' },
{ label: 'Ruby', value: 'ruby' },
{ label: 'Rust', value: 'rust' },
{ label: 'Sass', value: 'scss' },
{ label: 'Scala', value: 'scala' },
{ label: 'Scheme', value: 'scheme' },
{ label: 'SCSS', value: 'scss' },
{ label: 'Shell', value: 'shell' },
{ label: 'Smalltalk', value: 'smalltalk' },
{ label: 'Solidity', value: 'solidity' },
{ label: 'SQL', value: 'sql' },
{ label: 'Swift', value: 'swift' },
{ label: 'TOML', value: 'toml' },
{ label: 'TypeScript', value: 'typescript' },
{ label: 'VB.Net', value: 'vbnet' },
{ label: 'Verilog', value: 'verilog' },
{ label: 'VHDL', value: 'vhdl' },
{ label: 'Visual Basic', value: 'vbnet' },
{ label: 'WebAssembly', value: 'wasm' },
{ label: 'XML', value: 'xml' },
{ label: 'YAML', value: 'yaml' },
];
type CodeBlockLanguageOption = Readonly<{ label: string; value: string }>;
/** What a wrapper such as `withDiagrams` adds to either code-block variant. */
export type CodeBlockAddon = Readonly<{
languageOptions?: readonly CodeBlockLanguageOption[];
toolbar?: React.ReactNode;
wrapSource?: (source: React.ReactNode) => React.ReactNode;
}>;
export type CodeBlockElementProps = EditorElementProps<
typeof CodeBlockPlugin
> & {
addon?: CodeBlockAddon;
showLanguageLabel?: boolean;
};
/** What `createCodeBlockPlugin` takes: `CodeBlockElement`, `CodeBlockCodeMirrorElement` or a wrapped one. */
export type CodeBlockComponent = (
props: CodeBlockElementProps
) => React.ReactElement;
function getCodeBlockLanguageLabel(
lang: string | null | undefined,
addedOptions: readonly CodeBlockLanguageOption[] = []
) {
const value = lang?.trim();
if (!value) return null;
return (
[...codeBlockLanguages, ...addedOptions].find(
(language) => language.value === value
)?.label ?? value
);
}
export function CodeBlockElement({
addon,
showLanguageLabel = true,
...props
}: CodeBlockElementProps) {
return (
<CodeBlockContainer
addon={addon}
elementProps={props}
showLanguageLabel={showLanguageLabel}
>
<pre className="overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid">
<code className="[&>[data-editor-node=text]]:contents">
{props.children}
</code>
</pre>
</CodeBlockContainer>
);
}
export function CodeBlockContainer({
addon,
children,
elementProps,
languageOptions = codeBlockLanguages,
showLanguageLabel = true,
}: {
children: React.ReactNode;
elementProps: EditorElementProps<typeof CodeBlockPlugin>;
addon?: CodeBlockAddon;
languageOptions?: readonly CodeBlockLanguageOption[];
showLanguageLabel?: boolean;
}) {
const { editor, element, attributes, plugin, ref, slots } = elementProps;
const readOnly = useEditorReadOnly();
const addedOptions = addon?.languageOptions;
const options = React.useMemo(
() =>
addedOptions
? [
...languageOptions,
...addedOptions.filter(
(added) =>
!languageOptions.some((own) => own.value === added.value)
),
]
: languageOptions,
[addedOptions, languageOptions]
);
return (
<EditorElement
className="py-1 **:[.hljs-addition]:bg-[#f0fff4] **:[.hljs-addition]:text-[#22863a] dark:**:[.hljs-addition]:bg-[#3c5743] dark:**:[.hljs-addition]:text-[#ceead5] **:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#005cc5] dark:**:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#6596cf] **:[.hljs-built\\\\_in,.hljs-symbol]:text-[#e36209] dark:**:[.hljs-built\\\\_in,.hljs-symbol]:text-[#c3854e] **:[.hljs-bullet]:text-[#735c0f] **:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] dark:**:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] **:[.hljs-deletion]:bg-[#ffeef0] **:[.hljs-deletion]:text-[#b31d28] dark:**:[.hljs-deletion]:bg-[#473235] dark:**:[.hljs-deletion]:text-[#e7c7cb] **:[.hljs-emphasis]:italic **:[.hljs-keyword,.hljs-doctag,.hljs-temeditor-tag,.hljs-temeditor-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#d73a49] dark:**:[.hljs-keyword,.hljs-doctag,.hljs-temeditor-tag,.hljs-temeditor-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#ee6960] **:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#22863a] dark:**:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#36a84f] **:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#032f62] dark:**:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#3593ff] **:[.hljs-section]:font-bold **:[.hljs-section]:text-[#005cc5] dark:**:[.hljs-section]:text-[#61a5f2] **:[.hljs-strong]:font-bold **:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#6f42c1] dark:**:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#a77bfa]"
attributes={attributes}
element={element}
plugin={plugin}
ref={ref}
slots={slots}
>
<div className="relative rounded-md bg-muted/50">
{addon?.wrapSource ? addon.wrapSource(children) : children}
<div
className="absolute top-1 right-1 z-10 flex gap-0.5 select-none"
contentEditable={false}
>
{addon?.toolbar}
{!readOnly && element.language === 'json' && (
<Button
size="icon"
variant="ghost"
className="size-6 text-xs"
onClick={() => formatJsonCodeBlock(editor, element)}
title="Format code"
>
<BracesIcon className="!size-3.5 text-muted-foreground" />
</Button>
)}
<CodeBlockCombobox
addedOptions={addedOptions}
languageOptions={options}
showLanguageLabel={showLanguageLabel}
/>
<CodeBlockCopyButton />
</div>
</div>
</EditorElement>
);
}
export function formatJsonCodeBlock(editor: Editor, element: Element) {
if (editor.read.view.isReadOnly()) return;
const blockPath = editor.read.nodes.path(element);
if (!blockPath) return;
const block = editor.read.nodes.get(blockPath)?.[0];
if (
block !== element ||
!ElementApi.isElement(block) ||
block.type !== editor.plugin(CodeBlockPlugin).schema.type ||
block.language !== 'json' ||
block.children.length !== 1
) {
return;
}
const [child] = block.children;
if (!NodeApi.isText(child)) return;
const { text } = child;
let formatted: string;
try {
formatted = JSON.stringify(JSON.parse(text), null, 2);
} catch {
return;
}
if (formatted === text) return;
const diff = new DiffMatchPatch().diff_main(text, formatted, false);
const edits: Array<{ end: number; start: number; text: string }> = [];
let oldOffset = 0;
let start: number | null = null;
let replacement = '';
for (const [operation, value] of diff) {
if (operation === DiffOp.Equal) {
if (start !== null) {
edits.push({ start, end: oldOffset, text: replacement });
start = null;
replacement = '';
}
oldOffset += value.length;
} else if (operation === DiffOp.Delete) {
start ??= oldOffset;
oldOffset += value.length;
} else {
start ??= oldOffset;
replacement += value;
}
}
if (start !== null) {
edits.push({ start, end: oldOffset, text: replacement });
}
editor.update((tx) => {
for (const edit of edits.reverse()) {
const at = {
anchor: { path: [...blockPath, 0], offset: edit.start },
focus: { path: [...blockPath, 0], offset: edit.end },
};
if (edit.text) {
tx.text.insert(edit.text, { at });
} else {
tx.text.delete({ at });
}
}
});
}
function CodeBlockCopyButton() {
const editor = useEditor();
const element = useElement(CodeBlockPlugin);
return (
<CopyButton
size="icon"
variant="ghost"
className="size-6 gap-1 text-xs text-muted-foreground"
value={() => {
const path = editor.read.nodes.path(element);
return path ? editor.read.text.string(path) : '';
}}
/>
);
}
function CodeBlockCombobox({
addedOptions,
languageOptions,
showLanguageLabel,
}: {
languageOptions: readonly CodeBlockLanguageOption[];
showLanguageLabel: boolean;
addedOptions?: readonly CodeBlockLanguageOption[];
}) {
const [open, setOpen] = React.useState(false);
const readOnly = useEditorReadOnly();
const editor = useEditor();
const element = useElement(CodeBlockPlugin);
const value = element.language || 'plaintext';
const [searchValue, setSearchValue] = React.useState('');
const items = React.useMemo(
() =>
languageOptions.filter(
(language) =>
!searchValue ||
language.label.toLowerCase().includes(searchValue.toLowerCase())
),
[searchValue, languageOptions]
);
if (readOnly) {
if (!showLanguageLabel) return null;
return (
<CodeBlockLanguageLabel
addedOptions={addedOptions}
lang={element.language}
/>
);
}
return (
<FloatingPopover open={open} onOpenChange={setOpen}>
<FloatingPopoverTrigger>
<Button
size="sm"
variant="ghost"
className="h-6 justify-between gap-1 px-2 text-xs text-muted-foreground select-none"
aria-controls="code-block-language-options"
aria-expanded={open}
role="combobox"
>
{getCodeBlockLanguageLabel(value, addedOptions) ?? 'Plain Text'}
</Button>
</FloatingPopoverTrigger>
<FloatingPopoverContent
className="w-[200px] p-0"
id="code-block-language-options"
onFinalFocus={() => {
setSearchValue('');
}}
>
<Command shouldFilter={false}>
<CommandInput
className="h-9"
value={searchValue}
onValueChange={(innerValue) => {
setSearchValue(innerValue);
}}
placeholder="Search language..."
/>
<CommandEmpty>No language found.</CommandEmpty>
<CommandList className="h-[344px] overflow-y-auto">
<CommandGroup>
{items.map((language) => (
<CommandItem
key={language.label}
className="cursor-pointer"
value={language.value}
onSelect={(innerValue2) => {
const path = editor.read.nodes.path(element);
if (!path) return;
editor
.plugin(CodeBlockPlugin)
.update.set({ language: innerValue2 }, { at: path });
setSearchValue(innerValue2);
setOpen(false);
}}
>
<Check
className={cn(
value === language.value ? 'opacity-100' : 'opacity-0'
)}
/>
{language.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</FloatingPopoverContent>
</FloatingPopover>
);
}
function CodeBlockLanguageLabel({
addedOptions,
lang,
}: {
addedOptions?: readonly CodeBlockLanguageOption[];
lang?: string | null;
}) {
const label = getCodeBlockLanguageLabel(lang, addedOptions);
if (!label) return null;
return (
<span className="flex h-6 items-center px-2 text-xs text-muted-foreground select-none">
{label}
</span>
);
}
function CopyButton({
value,
...props
}: { value: (() => string) | string } & Omit<
React.ComponentProps<typeof Button>,
'value'
>) {
const [status, setStatus] = React.useState<
'idle' | 'copying' | 'copied' | 'failed'
>('idle');
React.useEffect(() => {
if (status !== 'copied') return undefined;
const timeout = setTimeout(() => {
setStatus('idle');
}, 2000);
return () => {
clearTimeout(timeout);
};
}, [status]);
return (
<Button
disabled={status === 'copying'}
onClick={async () => {
setStatus('copying');
try {
await navigator.clipboard.writeText(
typeof value === 'function' ? value() : value
);
setStatus('copied');
} catch {
setStatus('failed');
}
}}
title={status === 'failed' ? 'Copy failed. Try again.' : 'Copy'}
{...props}
>
<span className="sr-only">
{status === 'failed' ? 'Copy failed. Try again.' : 'Copy'}
</span>
{status === 'copied' ? (
<CheckIcon className="!size-3" />
) : (
<CopyIcon className="!size-3" />
)}
</Button>
);
}
export const createCodeBlockPlugin = (component: CodeBlockComponent) =>
CodeBlockPlugin.configure({
component,
inputRules: [CodeBlockRules.markdown({ on: 'match' })],
shortcuts: { toggle: { keys: 'mod+alt+8' } },
});
const lowlight = createCodeBlockLowlight();
export const CodeBlockKit = [
createCodeBlockPlugin(CodeBlockElement),
CodeHighlightPlugin.configure({
initialState: { lowlight },
}),
];
createEditor
({
plugins: [
// ...otherPlugins,
...CodeBlockKit,
],
});
'@lezer/highlight'
;
import { createCodeMirrorAdapter } from 'platejs/code-block/codemirror';
import {
CodeBlockContainer,
type CodeBlockElementProps,
} from '@/components/editor/code-block';
const syntaxColors = syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.keyword, class: 'hljs-keyword' },
{ tag: [tags.string, tags.regexp], class: 'hljs-string' },
{ tag: [tags.number, tags.bool, tags.null], class: 'hljs-number' },
{ tag: tags.comment, class: 'hljs-comment' },
{ tag: tags.function(tags.variableName), class: 'hljs-title function_' },
{ tag: [tags.typeName, tags.className], class: 'hljs-type' },
{ tag: tags.propertyName, class: 'hljs-attr' },
])
);
const languageDescription = (name?: string) =>
name ? LanguageDescription.matchLanguageName(languages, name, false) : null;
const codeMirrorLanguages = [
{ label: 'Plain Text', value: 'plaintext' },
...languages.map(({ name }) => ({ label: name, value: name.toLowerCase() })),
];
const codeMirrorAdapter = createCodeMirrorAdapter({
extensions: [
syntaxColors,
indentUnit.of(' '),
EditorState.tabSize.of(2),
search({ top: true }),
keymap.of(searchKeymap),
keymap.of(
[
{ key: 'Tab', run: indentMore, shift: indentLess },
...standardKeymap,
].map((binding) => ({ ...binding, scope: 'code-block' }))
),
EditorView.domEventHandlers({
keydown(event, view) {
// Composition starts before CodeMirror observes its first text mutation.
if (event.isComposing || view.compositionStarted) return false;
return runScopeHandlers(view, event, 'code-block');
},
}),
EditorView.theme({
'&': {
backgroundColor: 'transparent',
color: 'inherit',
},
'&.cm-focused': { outline: 'none' },
'.cm-content': {
caretColor: 'currentColor',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.875rem',
lineHeight: 'normal',
padding: '2rem 1rem 2rem 2rem',
},
'.cm-scroller': {
maxHeight: 'min(70vh, 48rem)',
overflow: 'auto',
},
'[data-code-block-model-selection]': {
backgroundColor: 'color-mix(in srgb, var(--brand) 25%, transparent)',
},
'.cm-search': {
alignItems: 'center',
backgroundColor: 'var(--background)',
borderBottom: '1px solid var(--border)',
display: 'flex',
flexWrap: 'wrap',
gap: '0.25rem',
padding: '0.375rem',
},
'.cm-search input': {
backgroundColor: 'var(--background)',
border: '1px solid var(--input)',
borderRadius: '0.375rem',
color: 'inherit',
padding: '0.25rem 0.5rem',
},
'.cm-search button': {
border: '1px solid var(--border)',
borderRadius: '0.375rem',
padding: '0.25rem 0.5rem',
},
'@media print': {
'.cm-scroller': {
maxHeight: 'none',
overflow: 'visible',
},
},
}),
],
loadLanguage(name) {
const description = languageDescription(name);
return description?.support ?? description?.load() ?? [];
},
});
export function CodeBlockCodeMirrorElement({
addon,
showLanguageLabel = true,
...props
}: CodeBlockElementProps) {
return (
<CodeBlockContainer
addon={addon}
elementProps={props}
languageOptions={codeMirrorLanguages}
showLanguageLabel={showLanguageLabel}
>
{props.slots.externalText({
adapter: codeMirrorAdapter,
ariaLabel: 'Code block',
config: { language: props.element.language },
})}
</CodeBlockContainer>
);
}
CodeBlockPlugin.name
?
codeBlockCodeMirrorPlugin
:
plugin
);
/** Fence languages this engine draws, canonical first, such as ['graphviz', 'dot']. */
languages: readonly [string, ...string[]];
label: string;
/**
* Resolves SVG markup, or rejects with a message the block shows. Once
* `signal` aborts, the block drops the result, so a renderer may stop early
* but does not have to.
*/
render: (source: string, options: { signal: AbortSignal }) => Promise<string>;
}>;
type DiagramView = 'both' | 'code';
const diagramViews: ReadonlyArray<{ label: string; value: DiagramView }> = [
{ label: 'Code', value: 'code' },
{ label: 'Both', value: 'both' },
];
type DiagramPicture = Readonly<{ error: string | null; url: string | null }>;
const emptyPicture: DiagramPicture = { error: null, url: null };
/** Draw the code blocks whose language one of `renderers` claims. */
export function withDiagrams(
Element: CodeBlockComponent,
{ renderers }: { renderers: readonly DiagramRenderer[] }
): CodeBlockComponent {
const rendererByLanguage = new Map<string, DiagramRenderer>();
for (const renderer of renderers) {
for (const language of renderer.languages) {
if (rendererByLanguage.has(language)) {
throw new Error(`Two diagram renderers claim "${language}".`);
}
rendererByLanguage.set(language, renderer);
}
}
const languageOptions = renderers.map((renderer) => ({
label: renderer.label,
value: renderer.languages[0],
}));
return function DiagramCodeBlockElement(props: CodeBlockElementProps) {
const renderer = rendererByLanguage.get(props.element.language ?? '');
const outerOptions = props.addon?.languageOptions;
const addon = {
...props.addon,
languageOptions: outerOptions
? [...outerOptions, ...languageOptions]
: languageOptions,
};
if (!renderer) return <Element {...props} addon={addon} />;
return (
<DiagramCodeBlock
addon={addon}
Element={Element}
elementProps={props}
renderer={renderer}
/>
);
};
}
function DiagramCodeBlock({
addon,
Element,
elementProps,
renderer,
}: {
addon: CodeBlockAddon;
Element: CodeBlockComponent;
elementProps: CodeBlockElementProps;
renderer: DiagramRenderer;
}) {
const { element } = elementProps;
const containerRef = React.useRef<HTMLDivElement>(null);
const [view, setView] = React.useState<DiagramView>('both');
const nearViewport = useNearViewport(containerRef);
const source = useLiveSource(element);
const picture = useDiagramPicture(
renderer,
source,
view === 'both' && nearViewport
);
return (
<Element
{...elementProps}
addon={{
...addon,
toolbar: (
<>
{addon.toolbar}
<DiagramViewToggle view={view} onPick={setView} />
{picture.url && <DiagramExportButton url={picture.url} />}
</>
),
wrapSource: (body) => (
<div ref={containerRef}>
{addon.wrapSource ? addon.wrapSource(body) : body}
{/* The editor restores child nodes added or removed beside the
source, so the picture area stays mounted and only hides. */}
<div
className="border-t border-border/60 px-8 py-4"
contentEditable={false}
hidden={view === 'code'}
>
{view === 'both' &&
picture.url && (
// oxlint-disable-next-line nextjs/no-img-element -- [P1 local-invariant] toDataUrl turns every rendered SVG into a data URL; optimization and remote loading do not apply.
<img
alt={element.language ?? renderer.label}
className="mx-auto max-h-96 max-w-full"
draggable={false}
src={picture.url}
/>
)}
{view === 'both' && (
<DiagramStatus
empty={source.trim().length === 0}
error={picture.error}
loading={!picture.url && !picture.error}
/>
)}
</div>
</div>
),
}}
/>
);
}
function DiagramViewToggle({
onPick,
view,
}: {
view: DiagramView;
onPick: (view: DiagramView) => void;
}) {
return (
<div className="flex items-center rounded-md" role="group">
{diagramViews.map((option) => (
<Button
key={option.value}
size="sm"
variant="ghost"
className={cn(
'h-6 px-2 text-xs text-muted-foreground',
view === option.value && 'bg-accent text-accent-foreground'
)}
aria-pressed={view === option.value}
onClick={() => onPick(option.value)}
onMouseDown={(event) => event.preventDefault()}
>
{option.label}
</Button>
))}
</div>
);
}
function DiagramExportButton({ url }: { url: string }) {
return (
<Button
size="icon"
variant="ghost"
className="size-6 text-muted-foreground"
onClick={(event) => {
const { ownerDocument } = event.currentTarget;
const image = ownerDocument.createElement('img');
image.addEventListener(
'load',
() => {
const { naturalHeight, naturalWidth } = image;
// A canvas past the browser's size limit exports as `data:,`. These
// bounds sit within every engine's, iOS Safari's 16,777,216 px included.
const scale = Math.min(
1,
16_384 / Math.max(naturalWidth, naturalHeight),
Math.sqrt(16_777_216 / (naturalWidth * naturalHeight))
);
const canvas = ownerDocument.createElement('canvas');
canvas.width = Math.max(1, Math.floor(naturalWidth * scale));
canvas.height = Math.max(1, Math.floor(naturalHeight * scale));
canvas
.getContext('2d')
?.drawImage(image, 0, 0, canvas.width, canvas.height);
const link = ownerDocument.createElement('a');
link.href = canvas.toDataURL('image/png');
link.download = 'diagram.png';
link.click();
},
{ once: true }
);
image.src = url;
}}
onMouseDown={(event) => event.preventDefault()}
title="Export PNG"
>
<DownloadIcon className="!size-3.5" />
</Button>
);
}
function DiagramStatus({
empty,
error,
loading,
}: {
empty: boolean;
error: string | null;
loading: boolean;
}) {
if (empty) {
return (
<span className="block text-sm text-muted-foreground">Empty diagram</span>
);
}
if (error) {
return (
<span className="block text-sm whitespace-pre-wrap text-destructive">
{error}
</span>
);
}
if (loading) {
return (
<span className="block text-sm text-muted-foreground">Drawing…</span>
);
}
return null;
}
function useLiveSource(element: CodeBlockElementProps['element']) {
const editor = useEditor();
const path = usePath();
const nodeKey = editor.read((state) => state.key(path));
return useEditorSelector(
(current) => NodeApi.string(current.read.nodes.get(path)?.[0] ?? element),
{
shouldUpdate: (change) =>
!change || !nodeKey || change.changed.hasNodeKey(nodeKey, 'node'),
}
);
}
function useNearViewport(ref: React.RefObject<HTMLElement | null>) {
const [near, setNear] = React.useState(
() => typeof IntersectionObserver === 'undefined'
);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof IntersectionObserver === 'undefined') return undefined;
const observer = new IntersectionObserver(
(entries) => setNear(entries.at(-1)?.isIntersecting ?? false),
{ rootMargin: '200px 0px' }
);
observer.observe(node);
return () => observer.disconnect();
}, [ref]);
return near;
}
const scheduleIdle = (callback: () => void) => {
if (typeof requestIdleCallback === 'function') {
const id = requestIdleCallback(callback);
return () => cancelIdleCallback(id);
}
const id = setTimeout(callback, 0);
return () => clearTimeout(id);
};
const toDataUrl = (svg: string) => {
const root = new DOMParser().parseFromString(
svg,
'image/svg+xml'
).documentElement;
if (root.localName !== 'svg') {
throw new Error('The renderer did not return an SVG picture.');
}
let binary = '';
for (const byte of new TextEncoder().encode(svg)) {
binary += String.fromCharCode(byte);
}
return `data:image/svg+xml;base64,${btoa(binary)}`;
};
function useDiagramPicture(
renderer: DiagramRenderer,
source: string,
enabled: boolean
): DiagramPicture {
const [picture, setPicture] = React.useState<
DiagramPicture & { drawnBy: DiagramRenderer | null }
>({ ...emptyPicture, drawnBy: null });
const singleFlight = React.useRef<{
enabled: boolean;
next: string | null;
renderer: DiagramRenderer;
requested: string | null;
unsettled: AbortController | null;
}>({ enabled, next: null, renderer, requested: null, unsettled: null });
const empty = source.trim().length === 0;
React.useEffect(() => {
const { current } = singleFlight;
return () => {
if (current.unsettled) {
current.unsettled.abort();
current.requested = null;
}
current.next = null;
};
}, []);
React.useEffect(() => {
const { current } = singleFlight;
const drain = () => {
const { next, renderer: drawing } = current;
if (current.unsettled || !current.enabled || next === null) return;
const controller = new AbortController();
current.next = null;
current.requested = next;
current.unsettled = controller;
drawing
.render(next, { signal: controller.signal })
.then(toDataUrl)
.then(
(url) => {
if (!controller.signal.aborted) {
setPicture({ drawnBy: drawing, error: null, url });
}
},
(error: unknown) => {
if (!controller.signal.aborted) {
setPicture((previous) => ({
drawnBy: drawing,
error: error instanceof Error ? error.message : String(error),
url: previous.drawnBy === drawing ? previous.url : null,
}));
}
}
)
.finally(() => {
current.unsettled = null;
scheduleIdle(drain);
});
};
current.enabled = enabled;
if (current.renderer !== renderer || empty) {
current.unsettled?.abort();
current.renderer = renderer;
current.requested = null;
}
current.next =
enabled && !empty && source !== current.requested ? source : null;
return current.next === null ? undefined : scheduleIdle(drain);
}, [empty, enabled, renderer, source]);
return empty || picture.drawnBy !== renderer ? emptyPicture : picture;
}
DiagramEditorKit
=
EditorKit.
map
((
plugin
)
=>
plugin.name === CodeBlockPlugin.name ? diagramCodeBlockPlugin : plugin
);
plugins: [
// ...otherPlugins,
CodeBlockPlugin,
],
});
'mod+alt+8'
} },
}),
CodeHighlightPlugin.configure({
initialState: { lowlight },
}),
],
});
import { CodeBlockRules } from 'platejs';
import { CodeBlockPlugin, CodeHighlightPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
import { CodeBlockElement } from '@/components/editor/code-block';
import { createCodeBlockLowlight } from '@/components/editor/code-block-lowlight';
 
const lowlight = createCodeBlockLowlight();
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    CodeBlockPlugin.configure({
      component: CodeBlockElement,
      inputRules: [CodeBlockRules.markdown({ on: 'match' })],
      shortcuts: { toggle: { keys: 'mod+alt+8' } },
    }),
    CodeHighlightPlugin.configure({
      initialState: { lowlight },
    }),
  ],
});
const editor = createEditor({
plugins: [
// ...otherPlugins,
CodeBlockPlugin.configure({
component: CodeBlockElement,
inputRules: [CodeBlockRules.markdown({ on: 'match' })],
shortcuts: { toggle: { keys: 'mod+alt+8' } },
}),
CodeHighlightPlugin.configure({
initialState: {
lowlight,
defaultLanguage: 'js', // Set default language (optional)
},
}),
],
});
import { createLowlight } from 'lowlight';
import css from 'highlight.js/lib/languages/css';
import js from 'highlight.js/lib/languages/javascript';
import ts from 'highlight.js/lib/languages/typescript';
import html from 'highlight.js/lib/languages/xml';
 
// Create a lowlight instance
const lowlight = createLowlight();
 
// Register only the languages you need
lowlight.register('html', html);
lowlight.register('css', css);
lowlight.register('js', js);
lowlight.register('ts', ts);
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    CodeBlockPlugin.configure({
      component: CodeBlockElement,
      inputRules: [CodeBlockRules.markdown({ on: 'match' })],
      shortcuts: { toggle: { keys: 'mod+alt+8' } },
    }),
    CodeHighlightPlugin.configure({
      initialState: {
        lowlight,
        defaultLanguage: 'js', // Set default language (optional)
      },
    }),
  ],
});
import { BaseCodeBlockPlugin, PLUGINS } from 'platejs';
 
const turnIntoItem = {
  icon: <FileCodeIcon />,
  label: 'Code',
  value: PLUGINS.codeBlock,
  onSelect: () => {
    const codeBlock = editor.plugin(BaseCodeBlockPlugin);
 
    if (!codeBlock.installed || editor.read.view.isReadOnly()) return;
 
    codeBlock.update.toggle();
  },
};
import { BaseCodeBlockPlugin, PLUGINS } from 'platejs';
 
const insertItem = {
  icon: <FileCodeIcon />,
  label: 'Code',
  value: PLUGINS.codeBlock,
  onSelect: () => {
    const codeBlock = editor.plugin(BaseCodeBlockPlugin);
 
    if (!codeBlock.installed || editor.read.view.isReadOnly()) return;
 
    codeBlock.update.insert({}, { select: true });
  },
};