Skip to content

Plugins and Renderers

Most integrations should start with renderer hooks. Reach for custom plugins when you need to decorate Markdown syntax nodes directly.

Galley includes plugins for headings, emphasis, inline code, fenced code, blockquotes, links, images, lists, checkboxes, dividers, and tables.

import { BUILT_IN_PLUGINS } from '@inkyquill/galley-editor';

Disable a built-in by id:

<GalleyEditor disabledPlugins={['ge:tables']} />

Add plugins alongside the built-ins:

<GalleyEditor plugins={[myPlugin]} />

Use renderer props for common customization points:

<GalleyEditor
codeHighlighter={({ code, language, theme }) => {
return highlight(code, language, theme);
}}
imageRenderer={(image) => {
const node = document.createElement('img');
node.src = image.url;
node.alt = image.alt;
return node;
}}
onLinkClick={(url, event) => {
event.preventDefault();
openInApp(url);
return true;
}}
/>

onLinkClick runs for Cmd/Ctrl-click link activation. Return true to suppress Galley’s default window.open behavior.

Use imageRenderer when images should render as product thumbnails, responsive figures, lazy-loaded media, or another DOM structure owned by your app.

<GalleyEditor
imageRenderer={({ alt, title, url, width, height }) => {
const image = document.createElement('img');
image.src = url;
image.alt = alt;
if (title) image.title = title;
image.loading = 'lazy';
image.style.display = 'block';
image.style.maxWidth = '100%';
if (width) image.style.width = `${width}px`;
if (height) image.style.height = `${height}px`;
return image;
}}
/>

Use missingImageRenderer for broken or empty images and imageControlsRenderer for selected-image controls. This is the pattern to build an image metadata editor, resize buttons, or a “show Markdown source” action without replacing the whole editor.

<GalleyEditor
missingImageRenderer={(image) => {
const node = document.createElement('span');
node.textContent = image.reason === 'empty-url' ? 'Missing URL' : 'Image failed';
return node;
}}
imageControlsRenderer={({ image, update, clearDimensions, revealSource }) => {
const node = document.createElement('button');
node.type = 'button';
node.textContent = `${image.width ?? 'auto'} x ${image.height ?? 'auto'}`;
node.onclick = () => update({ width: 640, height: 360 });
node.oncontextmenu = (event) => {
event.preventDefault();
clearDimensions();
revealSource();
};
return node;
}}
/>

The image metadata commands work well with those controls:

<GalleyEditor
imageControlsRenderer={({ image, update, clearDimensions, revealSource }) => {
const node = document.createElement('div');
const small = document.createElement('button');
small.type = 'button';
small.textContent = '320px';
small.onclick = () => update({ width: 320, height: 180 });
const clear = document.createElement('button');
clear.type = 'button';
clear.textContent = 'Clear size';
clear.onclick = () => clearDimensions();
const source = document.createElement('button');
source.type = 'button';
source.textContent = 'Source';
source.onclick = () => revealSource();
node.append(small, clear, source);
return node;
}}
/>

When controls run editor commands from inside a widget, prevent the button mouse-down from taking focus if the current selection matters.

Galley wraps source lines and rendered block content to the editor viewport by default. Fenced code and table cells therefore stay inside narrow host surfaces, including long unbroken tokens.

Enable one horizontal scrolling surface for the entire editor when unwrapped source is more important:

<GalleyEditor
value={markdown}
onChange={setMarkdown}
horizontalScroll
/>

With horizontalScroll, the main editor viewport scrolls horizontally. Rendered code and tables do not create nested horizontal scrollbars. Images keep their responsive max-width: 100% behavior.

See Storybook’s Constrained Block Layout and Horizontal Block Layout stories for the two modes.

Galley renders GitHub-flavored Markdown tables as an editable table block in live mode. Users can edit cells directly, use the block controls for rows, columns, and alignment, or Cmd/Ctrl-click the block to reveal the Markdown source. In read-only or preview-oriented screens, keep editable={false} so the table renders without editing affordances.

Rendered tables follow the editor-wide block width policy described above. They wrap within the viewport by default and participate in the main editor scrolling surface when horizontalScroll is enabled.

Use tableControlIcons to replace the visible labels or icons in the rendered table editor controls. The controls keep their built-in accessible aria-label and title tooltip values, so the custom icon only changes what is shown inside the button.

Each entry is keyed by a GalleyTableControlIconName:

KeyControl
insertRowBeforeAdd row before the selected row.
insertRowAfterAdd row after the selected row.
insertColumnBeforeAdd column before the selected column.
insertColumnAfterAdd column after the selected column.
deleteRowDelete the selected body row.
deleteColumnDelete the selected column.
alignLeftAlign the selected column left.
alignCenterAlign the selected column center.
alignRightAlign the selected column right.
clearAlignmentClear selected-column alignment.
editSourceReveal the Markdown table source.

Values can be strings, HTMLElements, or renderer functions. Renderer functions receive { name, label, view } and may return a string, an HTMLElement, or null. Returning null, omitting a key, or throwing from a renderer falls back to the built-in text for that control.

Use icon renderers for real products and short text only when it is clearer than an icon:

import { renderToStaticMarkup } from 'react-dom/server';
import {
AlignCenter,
AlignLeft,
AlignRight,
Code2,
Plus,
Trash2,
} from 'lucide-react';
function icon(node: React.ReactElement, label: string) {
const template = document.createElement('template');
template.innerHTML = renderToStaticMarkup(node);
const svg = template.content.firstElementChild as HTMLElement | null;
if (!svg) return null;
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
svg.setAttribute('title', label);
return svg;
}
<GalleyEditor
tableControlIcons={{
insertRowAfter: ({ label }) => icon(<Plus size={14} />, label),
deleteColumn: ({ label }) => icon(<Trash2 size={14} />, label),
alignLeft: ({ label }) => icon(<AlignLeft size={14} />, label),
alignCenter: ({ label }) => icon(<AlignCenter size={14} />, label),
alignRight: ({ label }) => icon(<AlignRight size={14} />, label),
editSource: ({ label }) => icon(<Code2 size={14} />, label),
clearAlignment: 'clear',
}}
/>

When you create icons in React, keep function renderers stable with useMemo or useCallback if they close over component state. Recreated but equivalent HTMLElement values are compared structurally, but function values are compared by reference so Galley can detect changed closures.

import { renderToStaticMarkup } from 'react-dom/server';
import { Code2, Plus } from 'lucide-react';
function icon(node: React.ReactElement, label: string) {
const template = document.createElement('template');
template.innerHTML = renderToStaticMarkup(node);
const svg = template.content.firstElementChild as HTMLElement | null;
if (!svg) return null;
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
svg.setAttribute('title', label);
return svg;
}
const tableControlIcons = useMemo(
() => ({
insertRowBefore: ({ label }) => icon(<Plus size={14} />, label),
insertRowAfter: ({ label }) => icon(<Plus size={14} />, label),
editSource: ({ label }) => icon(<Code2 size={14} />, label),
clearAlignment: 'clear',
}),
[],
);
<GalleyEditor tableControlIcons={tableControlIcons} />;

Use top-level toolbar icons for the editor chrome and tableControlIcons for the table block controls. They are separate surfaces: a custom Bold icon belongs in toolbar.icons, while custom insert-row and alignment icons belong in tableControlIcons.

A GalleyPlugin is a stable id plus a function that returns CodeMirror extensions:

import type { GalleyPlugin } from '@inkyquill/galley-editor';
export const myPlugin: GalleyPlugin = {
id: 'app:mentions',
extensions(classNames, context) {
return [myCodeMirrorExtension(classNames, context)];
},
};

For syntax-driven decorations, use the exported factories:

FactoryUse for
makeInlinePlugin(spec)Viewport-only inline marks and widgets.
makeBlockPlugin(spec)Multi-line or block decorations that need full-document iteration.

The plugin spec receives Lezer syntax nodes and the current editor state. Return a CodeMirror Decoration, WidgetType, or null.

Plugins control when rendered decorations hide and raw Markdown reappears:

StrategyBehavior
lineReveal when the cursor is on the same line.
activeReveal when the selection intersects the node or its parent.
selectReveal only when selection overlaps the node.
booleanProvide your own decision.

Use the narrowest strategy that keeps editing predictable.