Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Introduction
  • Installation
    • Plate UI
      • Next.js
      • React
    • Manual
    • RSC
    • Node.js
    • Local Docs
    • MCP
  • Releases

Code Drawing

PreviousNext
Code Drawing Node
Loading…
Code BlockColumn

On This Page

FeaturesKit usageInstallationAdd kitManual usageInstallationAdd pluginConfigure pluginAdd toolbar buttonPluginsCodeDrawingPluginAPIeditor.plugin(BaseCodeDrawingPlugin).update.insertTypesCodeDrawingElementCodeDrawingLanguageCodeDrawingViewUtilitiesrenderCodeDrawing
Build your editor
Production-ready AI template and reusable components.
Get all-access

Features

  • Support for PlantUML, Graphviz, Flowchart, and Mermaid diagram languages
  • Inline code editing with code-block-like styling
  • Real-time preview with debounced rendering (500ms)
  • Three persisted views: split, code, and preview
  • Responsive layout: horizontal on desktop (code left, preview right), vertical on mobile (preview top, code bottom)
  • Border separator between code editor and preview in Both mode
  • Toolbar controls: language selector and view mode selector (always visible on mobile, hover to show on desktop)
  • Download rendered diagrams as images
  • Floating toolbar with delete and download actions
Report an issue

Kit usage

Installation

The fastest way to add code drawing functionality is with the CodeDrawingKit, which includes the pre-configured CodeDrawingPlugin with its Plate UI components.

'use client';
 
import { DownloadIcon, Trash2 } from 'lucide-react';
import {
  type CodeDrawingLanguage,
  type CodeDrawingView,
  CODE_DRAWING_LANGUAGES,
  CODE_DRAWING_VIEWS,
  renderCodeDrawing,
} from 'platejs/code-drawing';
import { CodeDrawingPlugin } from 'platejs/code-drawing/react';
import {
  type EditorElementProps,
  EditorElement,
  useEditor,
  useEditorReadOnly,
  useEditorSelector,
  useElement,
  useElementSelected,
  useFocusedLast,
} from 'platejs/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import {
  FloatingPopover,
  FloatingPopoverAnchor,
  FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import { useIsMobile } from '@/hooks/use-mobile';
 
const DEFAULT_MIN_HEIGHT = 300;
 
const languageLabels: Record<CodeDrawingLanguage, string> = {
  flowchart: 'Flowchart',
  graphviz: 'Graphviz',
  mermaid: 'Mermaid',
  plantuml: 'PlantUML',
};
 
const viewLabels: Record<CodeDrawingView, string> = {
  code: 'Code',
  preview: 'Preview',
  split: 'Split',
};
 
const isCodeDrawingLanguage = (
  value: string | null
): value is CodeDrawingLanguage =>
  CODE_DRAWING_LANGUAGES.some((language) => language === value);
 
const isCodeDrawingView = (value: string | null): value is CodeDrawingView =>
  CODE_DRAWING_VIEWS.some((view) => view === value);
 
export function CodeDrawingElement({
  plantUmlServer = 'https://www.plantuml.com/plantuml',
  ...props
}: EditorElementProps<typeof CodeDrawingPlugin> & { plantUmlServer?: string }) {
  const { children } = props;
  const isMobile = useIsMobile();
  const editor = useEditor();
  const readOnly = useEditorReadOnly();
  const selected = useElementSelected();
  const isFocusedLast = useFocusedLast();
  const element = useElement(CodeDrawingPlugin);
  const { code, language, view } = element;
  const selectionCollapsed = useEditorSelector((innerEditor) =>
    innerEditor.read.selection.isCollapsed()
  );
  const open = isFocusedLast && !readOnly && selected && selectionCollapsed;
  const demanded = view !== 'code' || open;
  const wasDemanded = React.useRef(false);
  const [result, setResult] = React.useState<{
    code: string;
    language: CodeDrawingLanguage;
    plantUmlServer: string;
    image: string;
    error: string | null;
  } | null>(null);
  const currentResult =
    result?.code === code &&
    result.language === language &&
    result.plantUmlServer === plantUmlServer
      ? result
      : null;
  const image = currentResult?.image ?? '';
  const renderError = currentResult?.error ?? null;
  const loading = demanded && !!code.trim() && !currentResult;
 
  React.useEffect(() => {
    const activated = demanded && !wasDemanded.current;
    wasDemanded.current = demanded;
    if (!demanded || !code.trim() || currentResult) return undefined;
 
    let cancelled = false;
    const render = async () => {
      try {
        const imageData = await renderCodeDrawing(language, code, {
          plantUmlServer,
        });
        if (!cancelled) {
          setResult({
            code,
            language,
            plantUmlServer,
            image: imageData,
            error: null,
          });
        }
      } catch (error) {
        if (!cancelled) {
          const message =
            error instanceof Error ? error.message : 'Rendering failed';
          console.error(message);
          setResult({
            code,
            language,
            plantUmlServer,
            image: '',
            error: message,
          });
        }
      }
    };
    const timeout = window.setTimeout(
      () => {
        void render();
      },
      activated ? 0 : 500
    );
 
    return () => {
      cancelled = true;
      window.clearTimeout(timeout);
    };
  }, [code, currentResult, demanded, language, plantUmlServer]);
 
  const handleCodeChange = React.useCallback(
    (nextCode: string) => {
      const path = editor.read.nodes.path(element);
 
      if (path) {
        editor.update.nodes.set({ code: nextCode }, { at: path });
      }
    },
    [editor, element]
  );
 
  const handleLanguageChange = React.useCallback(
    (nextLanguage: CodeDrawingLanguage) => {
      const path = editor.read.nodes.path(element);
 
      if (path) {
        editor.update.nodes.set({ language: nextLanguage }, { at: path });
      }
    },
    [editor, element]
  );
 
  const handleViewChange = React.useCallback(
    (nextView: CodeDrawingView) => {
      const path = editor.read.nodes.path(element);
 
      if (path) {
        editor.update.nodes.set({ view: nextView }, { at: path });
      }
    },
    [editor, element]
  );
 
  const content = (
    <EditorElement {...props}>
      <div contentEditable={false}>
        <div>
          <CodeDrawingPreview
            code={code}
            language={language}
            view={view}
            image={image}
            loading={loading}
            renderError={renderError}
            onCodeChange={handleCodeChange}
            onLanguageChange={handleLanguageChange}
            onViewChange={handleViewChange}
            readOnly={readOnly}
            isMobile={isMobile}
          />
        </div>
      </div>
      {children}
    </EditorElement>
  );
 
  if (readOnly) {
    return content;
  }
 
  return (
    <FloatingPopover open={open} modal={false}>
      <FloatingPopoverAnchor element={content} />
      <FloatingPopoverContent
        className="w-auto p-1"
        contentEditable={false}
        onInitialFocus={(e) => {
          e.preventDefault();
        }}
      >
        <div className="flex items-center gap-1">
          {image && (
            <Button
              size="icon"
              variant="ghost"
              className="size-8"
              onClick={(event) => {
                const { ownerDocument } = event.currentTarget;
                const imageElement = ownerDocument.createElement('img');
                imageElement.addEventListener(
                  'load',
                  () => {
                    const canvas = ownerDocument.createElement('canvas');
                    canvas.width = imageElement.naturalWidth;
                    canvas.height = imageElement.naturalHeight;
                    const context = canvas.getContext('2d');
                    if (!context) return;
                    context.drawImage(imageElement, 0, 0);
                    const link = ownerDocument.createElement('a');
                    link.href = canvas.toDataURL('image/png');
                    link.download = 'code-drawing.png';
                    link.click();
                  },
                  { once: true }
                );
                imageElement.src = image;
              }}
              title="Export"
            >
              <DownloadIcon className="size-4" />
            </Button>
          )}
          <Button
            size="icon"
            variant="ghost"
            className="size-8"
            onClick={() => {
              const path = editor.read.nodes.path(element);
 
              if (!readOnly && path) editor.update.nodes.remove({ at: path });
            }}
            title="Delete"
          >
            <Trash2 className="size-4" />
          </Button>
        </div>
      </FloatingPopoverContent>
    </FloatingPopover>
  );
}
 
function CodeDrawingPreview({
  code,
  language,
  view,
  image,
  loading,
  renderError,
  onCodeChange,
  onLanguageChange,
  onViewChange,
  readOnly = false,
  isMobile = false,
}: {
  code: string;
  language: CodeDrawingLanguage;
  view: CodeDrawingView;
  image: string;
  loading: boolean;
  renderError: string | null;
  onCodeChange: (code: string) => void;
  onLanguageChange: (language: CodeDrawingLanguage) => void;
  onViewChange: (view: CodeDrawingView) => void;
  readOnly?: boolean;
  isMobile?: boolean;
}) {
  const viewMode = view;
  const showCode = viewMode === 'split' || viewMode === 'code';
  const showBorder = viewMode === 'split';
 
  const handleCodeChange = React.useCallback(
    (e: React.ChangeEvent<HTMLTextAreaElement>) => {
      onCodeChange(e.target.value);
    },
    [onCodeChange]
  );
 
  const toolbar = readOnly ? null : (
    <CodeDrawingToolbar
      language={language}
      viewMode={viewMode}
      readOnly={readOnly}
      isMobile={isMobile}
      onLanguageChange={onLanguageChange}
      onViewChange={onViewChange}
    />
  );
 
  return (
    <div
      className={`flex ${
        isMobile ? 'flex-col-reverse' : 'flex-col'
      } my-4 w-full items-stretch border bg-muted/50 md:flex-row hover:[&_[role=toolbar]]:opacity-100`}
      style={{
        minHeight: `${DEFAULT_MIN_HEIGHT}px`,
      }}
    >
      {showCode && (
        <CodeDrawingTextarea
          code={code}
          viewMode={viewMode}
          readOnly={readOnly}
          isMobile={isMobile}
          showBorder={showBorder}
          onCodeChange={handleCodeChange}
          toolbar={viewMode === 'code' ? toolbar : null}
        />
      )}
 
      {viewMode !== 'code' && (
        <CodeDrawingPreviewArea
          image={image}
          loading={loading}
          renderError={renderError}
          code={code}
          viewMode={viewMode}
          readOnly={readOnly}
          isMobile={isMobile}
          showBorder={showBorder}
          toolbar={toolbar}
        />
      )}
    </div>
  );
}
 
function CodeDrawingToolbar({
  language,
  viewMode,
  readOnly = false,
  isMobile = false,
  onLanguageChange,
  onViewChange,
}: {
  language: CodeDrawingLanguage;
  viewMode: CodeDrawingView;
  readOnly?: boolean;
  isMobile?: boolean;
  onLanguageChange: (language: CodeDrawingLanguage) => void;
  onViewChange: (view: CodeDrawingView) => void;
}) {
  const [toolbarVisible, setToolbarVisible] = React.useState(false);
  const [languageSelectOpen, setLanguageSelectOpen] = React.useState(false);
  const [viewSelectOpen, setViewSelectOpen] = React.useState(false);
 
  const opacityClass =
    isMobile || toolbarVisible || languageSelectOpen || viewSelectOpen
      ? 'opacity-100'
      : 'opacity-0';
 
  const positionClass = isMobile
    ? 'flex items-center gap-2'
    : 'absolute right-2 z-10 flex items-center gap-2';
 
  return (
    <div
      role="toolbar"
      tabIndex={-1}
      className={`${positionClass} transition-opacity ${opacityClass}`}
      onMouseEnter={() => {
        setToolbarVisible(true);
      }}
      onMouseLeave={() => {
        if (!languageSelectOpen && !viewSelectOpen) {
          setToolbarVisible(false);
        }
      }}
    >
      {!readOnly && (
        <Select
          value={language}
          onValueChange={(nextLanguage) => {
            if (isCodeDrawingLanguage(nextLanguage)) {
              onLanguageChange(nextLanguage);
            }
          }}
          open={languageSelectOpen}
          onOpenChange={setLanguageSelectOpen}
        >
          <SelectTrigger
            className={`h-8 w-[120px] border-0 bg-muted/50 text-xs shadow-none ${
              isMobile ? '' : 'transition-colors hover:bg-zinc-200'
            }`}
          >
            <SelectValue />
          </SelectTrigger>
          <SelectContent className="z-[100]">
            {CODE_DRAWING_LANGUAGES.map((innerLanguage) => (
              <SelectItem key={innerLanguage} value={innerLanguage}>
                {languageLabels[innerLanguage]}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      )}
 
      {!readOnly && (
        <Select
          value={viewMode}
          onValueChange={(nextView) => {
            if (isCodeDrawingView(nextView)) {
              onViewChange(nextView);
            }
          }}
          open={viewSelectOpen}
          onOpenChange={setViewSelectOpen}
        >
          <SelectTrigger
            className={`h-8 w-[80px] border-0 bg-muted/50 text-xs shadow-none ${
              isMobile ? '' : 'transition-colors hover:bg-zinc-200'
            }`}
          >
            <SelectValue />
          </SelectTrigger>
          <SelectContent className="z-[100]">
            {CODE_DRAWING_VIEWS.map((view) => (
              <SelectItem key={view} value={view}>
                {viewLabels[view]}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      )}
    </div>
  );
}
 
function CodeDrawingTextarea({
  code,
  viewMode,
  readOnly = false,
  isMobile = false,
  showBorder = false,
  onCodeChange,
  toolbar,
}: {
  code: string;
  viewMode: CodeDrawingView;
  readOnly?: boolean;
  isMobile?: boolean;
  showBorder?: boolean;
  onCodeChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
  toolbar?: React.ReactNode;
}) {
  const textareaRef = React.useRef<HTMLTextAreaElement>(null);
  const isCodeOnlyMode = viewMode === 'code';
 
  const [internalCode, setInternalCode] = React.useState(code);
  const lastExternalCodeRef = React.useRef(code);
 
  React.useEffect(() => {
    if (code !== lastExternalCodeRef.current) {
      lastExternalCodeRef.current = code;
      setInternalCode(code);
    }
  }, [code]);
 
  const handleChange = React.useCallback(
    (e: React.ChangeEvent<HTMLTextAreaElement>) => {
      const newValue = e.target.value;
      setInternalCode(newValue);
      onCodeChange(e);
    },
    [onCodeChange]
  );
 
  return (
    <div
      className={`${
        isCodeOnlyMode ? 'w-full' : 'min-w-0 flex-1'
      } flex flex-col ${isCodeOnlyMode && !isMobile ? 'relative' : ''} ${
        showBorder && !isMobile ? 'border-r' : ''
      }`}
    >
      {toolbar && isCodeOnlyMode && (
        <div
          className={
            isMobile
              ? 'mt-2 mb-2 flex justify-end px-2'
              : 'absolute right-2 z-10 mt-2'
          }
        >
          {toolbar}
        </div>
      )}
 
      <div className="relative flex-1 rounded-md">
        <pre
          className="m-0 overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid"
          style={{ minHeight: `${DEFAULT_MIN_HEIGHT}px`, height: '100%' }}
        >
          <code className="block h-full w-full">
            <textarea
              ref={textareaRef}
              value={internalCode}
              onChange={handleChange}
              readOnly={readOnly}
              className="m-0 h-full w-full resize-none overflow-auto border-0 bg-transparent p-0 font-mono text-sm outline-none"
              style={{ minHeight: `${DEFAULT_MIN_HEIGHT}px` }}
              placeholder="Enter your code here..."
              spellCheck={false}
            />
          </code>
        </pre>
      </div>
    </div>
  );
}
 
function CodeDrawingPreviewArea({
  image,
  loading,
  renderError,
  code,
  viewMode,
  readOnly: _readOnly = false,
  isMobile = false,
  showBorder = false,
  toolbar,
}: {
  image: string;
  loading: boolean;
  renderError: string | null;
  code: string;
  viewMode: CodeDrawingView;
  readOnly?: boolean;
  isMobile?: boolean;
  showBorder?: boolean;
  toolbar?: React.ReactNode;
}) {
  const showPreview = viewMode === 'split' || viewMode === 'preview';
 
  return (
    <div
      className={`flex min-w-0 flex-1 flex-col ${isMobile ? '' : 'relative'} ${
        showBorder && isMobile ? 'border-b' : ''
      }`}
    >
      {toolbar && (
        <div
          className={
            isMobile
              ? 'mt-2 mb-2 flex justify-end px-2'
              : 'absolute right-2 z-10 mt-2'
          }
        >
          {toolbar}
        </div>
      )}
 
      {showPreview ? (
        <div className="flex flex-1 items-center justify-center rounded-md bg-muted/30 p-4">
          {loading && <div className="text-muted-foreground">Loading...</div>}
          {!loading &&
            image && (
              // oxlint-disable-next-line nextjs/no-img-element -- [P1 local-invariant] The renderer returns an ephemeral preview data URL; optimization and remote loading do not apply.
              <img
                src={image}
                alt="code drawing"
                className="max-h-full max-w-full object-contain"
              />
            )}
          {!loading && !image && renderError && (
            <div className="text-destructive" title={renderError}>
              Could not render preview. Edit the source to retry.
            </div>
          )}
          {!loading && !image && !renderError && (
            <div className="text-muted-foreground">
              {code.trim() ? 'Rendering...' : 'Preview will appear here'}
            </div>
          )}
        </div>
      ) : (
        <div className="pointer-events-none flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4 opacity-0">
          {/* Placeholder to maintain height */}
        </div>
      )}
    </div>
  );
}
 
export const CodeDrawingKit = [
  CodeDrawingPlugin.configure({ component: CodeDrawingElement }),
];
'use client';
 
import { DownloadIcon, Trash2 } from 'lucide-react';
import {
  type CodeDrawingLanguage,
  type CodeDrawingView,
  CODE_DRAWING_LANGUAGES,
  CODE_DRAWING_VIEWS,
  renderCodeDrawing,
} from 'platejs/code-drawing';
import { CodeDrawingPlugin } from 'platejs/code-drawing/react';
import {
  type EditorElementProps,
  EditorElement,
  useEditor,
  useEditorReadOnly,
  useEditorSelector,
  useElement,
  useElementSelected,
  useFocusedLast,
} from 'platejs/react';
import * as










































































































































































































































































































































































































































































































































































































  • CodeDrawingElement: Renders code drawing elements with inline editing and preview.

Add kit

Add the kit to your plugins:

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




Manual usage

Installation

pnpm add platejs @tanstack/react-virtual flowchart.js mermaid plantuml-encoder viz.js
pnpm add platejs @tanstack/react-virtual flowchart.js mermaid plantuml-encoder viz.js

Add plugin

Include CodeDrawingPlugin in your Plate plugins array when creating the editor.

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




Configure plugin

Configure the code drawing plugin with custom components.

import { CodeDrawingPlugin } from 'platejs/code-drawing/react';
import { createEditor } from 'platejs/react';
import { CodeDrawingElement } from '@/components/editor/code-drawing';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    CodeDrawingPlugin.configure({ component: CodeDrawingElement }),
  ],
});
import { CodeDrawingPlugin } from 'platejs/code-drawing/react';








  • component: Assigns CodeDrawingElement to render code drawing elements.

Add toolbar button

Add an item that calls the installed code-drawing feature. Code Drawing is an insertion action; it is not a Turn Into conversion.

import { BaseCodeDrawingPlugin } from 'platejs/code-drawing';
 
const insertItem = {
  icon: <Code2Icon />,
  label: 'Code Drawing',
  value: 'code_drawing',
  onSelect: () => {
    const codeDrawing = editor.plugin(BaseCodeDrawingPlugin);
 
    if (!codeDrawing.installed || editor.read.view.isReadOnly()) return;
 
    codeDrawing.update.insert({}, { select: true });
  },
};

Plugins

CodeDrawingPlugin

Plugin for rendering code drawings with inline editing and preview capabilities.

API

editor.plugin(BaseCodeDrawingPlugin).update.insert

Inserts a code drawing element at the current selection.

Parameters

    Construction values for the code drawing element. Schema defaults make every canonical field present.

    Use after?: NodeTarget to choose a reference block and select?: boolean to select the drawing. Without a reference or selection, the command appends the drawing to the document.

Types

CodeDrawingElement

The code drawing element type.

Attributes

    The serialized element type. Its default is codeDrawing; read the installed value from editor.plugin(BaseCodeDrawingPlugin).schema.type.

    The diagram source. Defaults to an empty string.

    The lowercase diagram-language identifier. Defaults to mermaid.

    The persisted presentation view. Defaults to split.

CodeDrawingLanguage

The type of diagram supported.

Returns

    The persisted diagram language.

CodeDrawingView

The view mode for code drawing elements.

Returns

    The view mode.

    • 'split': Shows code and preview together.
    • 'code': Shows only the code editor.
    • 'preview': Shows only the rendered preview.

Utilities

renderCodeDrawing

Renders a diagram from code based on its language.

PlantUML requires an explicit rendering server. Mermaid, Graphviz, and Flowchart use their installed rendering libraries.

const image = await renderCodeDrawing('plantuml', source, {
  plantUmlServer: 'https://www.plantuml.com/plantuml',
});
const image = await renderCodeDrawing('plantuml', source, {
  plantUmlServer: 'https://www.plantuml.com/plantuml',
});

Parameters

    The diagram language to render.

    The code content for the diagram.

    Set plantUmlServer to the server base URL when rendering PlantUML. The renderer appends /svg/ and the encoded diagram source.

Returns

    A promise that resolves to the rendered image data URL.

The copied CodeDrawingElement accepts a plantUmlServer prop and defaults to https://www.plantuml.com/plantuml. Its Export button saves a PNG. Customize the filename, preview height, and render debounce directly in the copied component.

React
from
'react'
;
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
FloatingPopover,
FloatingPopoverAnchor,
FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import { useIsMobile } from '@/hooks/use-mobile';
const DEFAULT_MIN_HEIGHT = 300;
const languageLabels: Record<CodeDrawingLanguage, string> = {
flowchart: 'Flowchart',
graphviz: 'Graphviz',
mermaid: 'Mermaid',
plantuml: 'PlantUML',
};
const viewLabels: Record<CodeDrawingView, string> = {
code: 'Code',
preview: 'Preview',
split: 'Split',
};
const isCodeDrawingLanguage = (
value: string | null
): value is CodeDrawingLanguage =>
CODE_DRAWING_LANGUAGES.some((language) => language === value);
const isCodeDrawingView = (value: string | null): value is CodeDrawingView =>
CODE_DRAWING_VIEWS.some((view) => view === value);
export function CodeDrawingElement({
plantUmlServer = 'https://www.plantuml.com/plantuml',
...props
}: EditorElementProps<typeof CodeDrawingPlugin> & { plantUmlServer?: string }) {
const { children } = props;
const isMobile = useIsMobile();
const editor = useEditor();
const readOnly = useEditorReadOnly();
const selected = useElementSelected();
const isFocusedLast = useFocusedLast();
const element = useElement(CodeDrawingPlugin);
const { code, language, view } = element;
const selectionCollapsed = useEditorSelector((innerEditor) =>
innerEditor.read.selection.isCollapsed()
);
const open = isFocusedLast && !readOnly && selected && selectionCollapsed;
const demanded = view !== 'code' || open;
const wasDemanded = React.useRef(false);
const [result, setResult] = React.useState<{
code: string;
language: CodeDrawingLanguage;
plantUmlServer: string;
image: string;
error: string | null;
} | null>(null);
const currentResult =
result?.code === code &&
result.language === language &&
result.plantUmlServer === plantUmlServer
? result
: null;
const image = currentResult?.image ?? '';
const renderError = currentResult?.error ?? null;
const loading = demanded && !!code.trim() && !currentResult;
React.useEffect(() => {
const activated = demanded && !wasDemanded.current;
wasDemanded.current = demanded;
if (!demanded || !code.trim() || currentResult) return undefined;
let cancelled = false;
const render = async () => {
try {
const imageData = await renderCodeDrawing(language, code, {
plantUmlServer,
});
if (!cancelled) {
setResult({
code,
language,
plantUmlServer,
image: imageData,
error: null,
});
}
} catch (error) {
if (!cancelled) {
const message =
error instanceof Error ? error.message : 'Rendering failed';
console.error(message);
setResult({
code,
language,
plantUmlServer,
image: '',
error: message,
});
}
}
};
const timeout = window.setTimeout(
() => {
void render();
},
activated ? 0 : 500
);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [code, currentResult, demanded, language, plantUmlServer]);
const handleCodeChange = React.useCallback(
(nextCode: string) => {
const path = editor.read.nodes.path(element);
if (path) {
editor.update.nodes.set({ code: nextCode }, { at: path });
}
},
[editor, element]
);
const handleLanguageChange = React.useCallback(
(nextLanguage: CodeDrawingLanguage) => {
const path = editor.read.nodes.path(element);
if (path) {
editor.update.nodes.set({ language: nextLanguage }, { at: path });
}
},
[editor, element]
);
const handleViewChange = React.useCallback(
(nextView: CodeDrawingView) => {
const path = editor.read.nodes.path(element);
if (path) {
editor.update.nodes.set({ view: nextView }, { at: path });
}
},
[editor, element]
);
const content = (
<EditorElement {...props}>
<div contentEditable={false}>
<div>
<CodeDrawingPreview
code={code}
language={language}
view={view}
image={image}
loading={loading}
renderError={renderError}
onCodeChange={handleCodeChange}
onLanguageChange={handleLanguageChange}
onViewChange={handleViewChange}
readOnly={readOnly}
isMobile={isMobile}
/>
</div>
</div>
{children}
</EditorElement>
);
if (readOnly) {
return content;
}
return (
<FloatingPopover open={open} modal={false}>
<FloatingPopoverAnchor element={content} />
<FloatingPopoverContent
className="w-auto p-1"
contentEditable={false}
onInitialFocus={(e) => {
e.preventDefault();
}}
>
<div className="flex items-center gap-1">
{image && (
<Button
size="icon"
variant="ghost"
className="size-8"
onClick={(event) => {
const { ownerDocument } = event.currentTarget;
const imageElement = ownerDocument.createElement('img');
imageElement.addEventListener(
'load',
() => {
const canvas = ownerDocument.createElement('canvas');
canvas.width = imageElement.naturalWidth;
canvas.height = imageElement.naturalHeight;
const context = canvas.getContext('2d');
if (!context) return;
context.drawImage(imageElement, 0, 0);
const link = ownerDocument.createElement('a');
link.href = canvas.toDataURL('image/png');
link.download = 'code-drawing.png';
link.click();
},
{ once: true }
);
imageElement.src = image;
}}
title="Export"
>
<DownloadIcon className="size-4" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="size-8"
onClick={() => {
const path = editor.read.nodes.path(element);
if (!readOnly && path) editor.update.nodes.remove({ at: path });
}}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
</div>
</FloatingPopoverContent>
</FloatingPopover>
);
}
function CodeDrawingPreview({
code,
language,
view,
image,
loading,
renderError,
onCodeChange,
onLanguageChange,
onViewChange,
readOnly = false,
isMobile = false,
}: {
code: string;
language: CodeDrawingLanguage;
view: CodeDrawingView;
image: string;
loading: boolean;
renderError: string | null;
onCodeChange: (code: string) => void;
onLanguageChange: (language: CodeDrawingLanguage) => void;
onViewChange: (view: CodeDrawingView) => void;
readOnly?: boolean;
isMobile?: boolean;
}) {
const viewMode = view;
const showCode = viewMode === 'split' || viewMode === 'code';
const showBorder = viewMode === 'split';
const handleCodeChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onCodeChange(e.target.value);
},
[onCodeChange]
);
const toolbar = readOnly ? null : (
<CodeDrawingToolbar
language={language}
viewMode={viewMode}
readOnly={readOnly}
isMobile={isMobile}
onLanguageChange={onLanguageChange}
onViewChange={onViewChange}
/>
);
return (
<div
className={`flex ${
isMobile ? 'flex-col-reverse' : 'flex-col'
} my-4 w-full items-stretch border bg-muted/50 md:flex-row hover:[&_[role=toolbar]]:opacity-100`}
style={{
minHeight: `${DEFAULT_MIN_HEIGHT}px`,
}}
>
{showCode && (
<CodeDrawingTextarea
code={code}
viewMode={viewMode}
readOnly={readOnly}
isMobile={isMobile}
showBorder={showBorder}
onCodeChange={handleCodeChange}
toolbar={viewMode === 'code' ? toolbar : null}
/>
)}
{viewMode !== 'code' && (
<CodeDrawingPreviewArea
image={image}
loading={loading}
renderError={renderError}
code={code}
viewMode={viewMode}
readOnly={readOnly}
isMobile={isMobile}
showBorder={showBorder}
toolbar={toolbar}
/>
)}
</div>
);
}
function CodeDrawingToolbar({
language,
viewMode,
readOnly = false,
isMobile = false,
onLanguageChange,
onViewChange,
}: {
language: CodeDrawingLanguage;
viewMode: CodeDrawingView;
readOnly?: boolean;
isMobile?: boolean;
onLanguageChange: (language: CodeDrawingLanguage) => void;
onViewChange: (view: CodeDrawingView) => void;
}) {
const [toolbarVisible, setToolbarVisible] = React.useState(false);
const [languageSelectOpen, setLanguageSelectOpen] = React.useState(false);
const [viewSelectOpen, setViewSelectOpen] = React.useState(false);
const opacityClass =
isMobile || toolbarVisible || languageSelectOpen || viewSelectOpen
? 'opacity-100'
: 'opacity-0';
const positionClass = isMobile
? 'flex items-center gap-2'
: 'absolute right-2 z-10 flex items-center gap-2';
return (
<div
role="toolbar"
tabIndex={-1}
className={`${positionClass} transition-opacity ${opacityClass}`}
onMouseEnter={() => {
setToolbarVisible(true);
}}
onMouseLeave={() => {
if (!languageSelectOpen && !viewSelectOpen) {
setToolbarVisible(false);
}
}}
>
{!readOnly && (
<Select
value={language}
onValueChange={(nextLanguage) => {
if (isCodeDrawingLanguage(nextLanguage)) {
onLanguageChange(nextLanguage);
}
}}
open={languageSelectOpen}
onOpenChange={setLanguageSelectOpen}
>
<SelectTrigger
className={`h-8 w-[120px] border-0 bg-muted/50 text-xs shadow-none ${
isMobile ? '' : 'transition-colors hover:bg-zinc-200'
}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[100]">
{CODE_DRAWING_LANGUAGES.map((innerLanguage) => (
<SelectItem key={innerLanguage} value={innerLanguage}>
{languageLabels[innerLanguage]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{!readOnly && (
<Select
value={viewMode}
onValueChange={(nextView) => {
if (isCodeDrawingView(nextView)) {
onViewChange(nextView);
}
}}
open={viewSelectOpen}
onOpenChange={setViewSelectOpen}
>
<SelectTrigger
className={`h-8 w-[80px] border-0 bg-muted/50 text-xs shadow-none ${
isMobile ? '' : 'transition-colors hover:bg-zinc-200'
}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[100]">
{CODE_DRAWING_VIEWS.map((view) => (
<SelectItem key={view} value={view}>
{viewLabels[view]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
}
function CodeDrawingTextarea({
code,
viewMode,
readOnly = false,
isMobile = false,
showBorder = false,
onCodeChange,
toolbar,
}: {
code: string;
viewMode: CodeDrawingView;
readOnly?: boolean;
isMobile?: boolean;
showBorder?: boolean;
onCodeChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
toolbar?: React.ReactNode;
}) {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const isCodeOnlyMode = viewMode === 'code';
const [internalCode, setInternalCode] = React.useState(code);
const lastExternalCodeRef = React.useRef(code);
React.useEffect(() => {
if (code !== lastExternalCodeRef.current) {
lastExternalCodeRef.current = code;
setInternalCode(code);
}
}, [code]);
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInternalCode(newValue);
onCodeChange(e);
},
[onCodeChange]
);
return (
<div
className={`${
isCodeOnlyMode ? 'w-full' : 'min-w-0 flex-1'
} flex flex-col ${isCodeOnlyMode && !isMobile ? 'relative' : ''} ${
showBorder && !isMobile ? 'border-r' : ''
}`}
>
{toolbar && isCodeOnlyMode && (
<div
className={
isMobile
? 'mt-2 mb-2 flex justify-end px-2'
: 'absolute right-2 z-10 mt-2'
}
>
{toolbar}
</div>
)}
<div className="relative flex-1 rounded-md">
<pre
className="m-0 overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid"
style={{ minHeight: `${DEFAULT_MIN_HEIGHT}px`, height: '100%' }}
>
<code className="block h-full w-full">
<textarea
ref={textareaRef}
value={internalCode}
onChange={handleChange}
readOnly={readOnly}
className="m-0 h-full w-full resize-none overflow-auto border-0 bg-transparent p-0 font-mono text-sm outline-none"
style={{ minHeight: `${DEFAULT_MIN_HEIGHT}px` }}
placeholder="Enter your code here..."
spellCheck={false}
/>
</code>
</pre>
</div>
</div>
);
}
function CodeDrawingPreviewArea({
image,
loading,
renderError,
code,
viewMode,
readOnly: _readOnly = false,
isMobile = false,
showBorder = false,
toolbar,
}: {
image: string;
loading: boolean;
renderError: string | null;
code: string;
viewMode: CodeDrawingView;
readOnly?: boolean;
isMobile?: boolean;
showBorder?: boolean;
toolbar?: React.ReactNode;
}) {
const showPreview = viewMode === 'split' || viewMode === 'preview';
return (
<div
className={`flex min-w-0 flex-1 flex-col ${isMobile ? '' : 'relative'} ${
showBorder && isMobile ? 'border-b' : ''
}`}
>
{toolbar && (
<div
className={
isMobile
? 'mt-2 mb-2 flex justify-end px-2'
: 'absolute right-2 z-10 mt-2'
}
>
{toolbar}
</div>
)}
{showPreview ? (
<div className="flex flex-1 items-center justify-center rounded-md bg-muted/30 p-4">
{loading && <div className="text-muted-foreground">Loading...</div>}
{!loading &&
image && (
// oxlint-disable-next-line nextjs/no-img-element -- [P1 local-invariant] The renderer returns an ephemeral preview data URL; optimization and remote loading do not apply.
<img
src={image}
alt="code drawing"
className="max-h-full max-w-full object-contain"
/>
)}
{!loading && !image && renderError && (
<div className="text-destructive" title={renderError}>
Could not render preview. Edit the source to retry.
</div>
)}
{!loading && !image && !renderError && (
<div className="text-muted-foreground">
{code.trim() ? 'Rendering...' : 'Preview will appear here'}
</div>
)}
</div>
) : (
<div className="pointer-events-none flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4 opacity-0">
{/* Placeholder to maintain height */}
</div>
)}
</div>
);
}
export const CodeDrawingKit = [
CodeDrawingPlugin.configure({ component: CodeDrawingElement }),
];
createEditor
({
plugins: [
// ...otherPlugins,
...CodeDrawingKit,
],
});
({
plugins: [
// ...otherPlugins,
CodeDrawingPlugin,
],
});
import { createEditor } from 'platejs/react';
import { CodeDrawingElement } from '@/components/editor/code-drawing';
const editor = createEditor({
plugins: [
// ...otherPlugins,
CodeDrawingPlugin.configure({ component: CodeDrawingElement }),
],
});
import { BaseCodeDrawingPlugin } from 'platejs/code-drawing';
 
const insertItem = {
  icon: <Code2Icon />,
  label: 'Code Drawing',
  value: 'code_drawing',
  onSelect: () => {
    const codeDrawing = editor.plugin(BaseCodeDrawingPlugin);
 
    if (!codeDrawing.installed || editor.read.view.isReadOnly()) return;
 
    codeDrawing.update.insert({}, { select: true });
  },
};