rc-textarea plugins
Plugins add marks, line decorations, and widgets over rc-textarea's plain-text
value without changing what the form submits.
Basics
Mental model
rc-textarea stores one plain-text value. Plugins add visual decorations over that value:
- Mark decorations style character ranges.
- Line decorations add classes, attributes, gutter content, or diagnostic messages to logical lines.
- Widget decorations insert non-editable inline, block, or trailing elements at character offsets.
- Fold decorations hide complete source lines without removing them from the value.
Decorations never change the submitted textarea value. Offsets are always absolute character indices into the current plain-text value unless a helper explicitly says otherwise.
Only one plugin is active at a time. usePlugin() or assigning the plugin property replaces
the previous plugin and calls its destroy() hook.
Quick start
import type { RCTextareaPlugin, RCTextareaPluginAPI } from '@rcarls/rc-textarea';
const editor = document.querySelector('rc-textarea');
let api: RCTextareaPluginAPI | undefined;
const plugin: RCTextareaPlugin = {
mount(pluginApi) {
api = pluginApi;
},
update(value, pluginApi) {
pluginApi.setDecorations(parseDiagnostics(value));
},
destroy() {
api = undefined;
},
};
editor.usePlugin(plugin);
Use mount() to capture the live API object, update() or highlight() to provide
decorations, and destroy() to remove external subscriptions.
Plugin interface
interface RCTextareaPlugin {
mount?(api: RCTextareaPluginAPI): void;
destroy?(): void;
/** Display-layer transform; only while the textarea is readonly. */
transform?(value: string, api: RCTextareaPluginAPI): string | null | void;
/** Imperative decoration hook. May be async. */
update?(value: string, api: RCTextareaPluginAPI): void | Promise<void>;
/** HTML compatibility hook; return HTML token spans from a highlighter. */
highlight?(
value: string,
api: RCTextareaPluginAPI,
): string | null | void | Promise<string | null | void>;
}
update() is the general-purpose hook. Use it when you already have decoration objects from a
parser, linter, or tokenizer.
highlight() is the compatibility hook for highlighters that return HTML. The component parses
the returned token spans with api.parseDecorationsFromHtml() and adds mark decorations; the
HTML is never inserted into the editor.
transform() runs only while the slotted textarea is readonly and substitutes the
display-layer text without changing editor.value or the slotted textarea value.
Async update() and highlight() hooks are allowed. If a newer render starts while an async
hook is pending, stale results are discarded.
External updates
Call scheduleUpdate() when external state changes but the text value does not:
let removeThemeListener: (() => void) | undefined;
editor.usePlugin({
mount(api) {
const onThemeChange = () => api.scheduleUpdate();
window.addEventListener('theme-change', onThemeChange);
removeThemeListener = () => window.removeEventListener('theme-change', onThemeChange);
},
update(value, api) {
api.setDecorations(decorate(value, currentTheme));
},
destroy() {
removeThemeListener?.();
},
});
Decorations
When passing decorations to the API, omit id; the component assigns IDs.
type DecorationInput =
| Omit<MarkDecoration, 'id'>
| Omit<LineDecoration, 'id'>
| Omit<WidgetDecoration, 'id'>
| Omit<FoldDecoration, 'id'>;
MarkDecoration
Styles a character range [from, to).
interface MarkDecoration {
id: string;
type: 'mark';
from: number; // inclusive, 0-based character offset
to: number; // exclusive
className?: string;
bold?: boolean;
italic?: boolean;
color?: string;
background?: string;
underline?: 'solid' | 'wavy' | 'dotted' | 'dashed';
underlineColor?: string;
attributes?: Record<string, string>;
}
api.setDecorations([{ type: 'mark', from: 0, to: 5, className: 'keyword', bold: true }]);
LineDecoration
Adds a class, gutter content, or diagnostic message to a logical line.
interface LineDecoration {
id: string;
type: 'line';
line: number; // 1-based
className?: string;
message?: string; // rendered via ::after; not in selection or clipboard
messageClassName?: string;
attributes?: Record<string, string>;
gutterContent?: string | HTMLElement | null; // null: suppress; undefined: use default
gutterLineNumber?: boolean; // keep the built-in number before custom content
gutterAccessible?: boolean; // expose this cell when it contains a control
}
api.setDecorations([
{
type: 'line',
line: 3,
className: 'line-error',
message: 'Missing semicolon',
messageClassName: 'error',
gutterContent: '!',
},
]);
message renders at the end of the line through .line[data-message]::after, so it is visual
and not part of the editable text or clipboard. Style it through an adopted stylesheet:
api.adoptStyleSheet(`
.line[data-message-class~="error"][data-message]::after {
content: attr(data-message);
color: var(--editor-diagnostic-error);
font-style: italic;
margin-inline-start: 1em;
}
`);
Use gutterContent: null to suppress the built-in gutter content for a line. An HTMLElement
supplies a plugin-owned marker or control; set gutterAccessible: true when the cell contains a
control, since other cells stay hidden from assistive technology. Set gutterLineNumber: true
to keep the built-in number before custom content when the host also has line-numbers.
WidgetDecoration
Inserts a non-editable element at a character offset. Widgets have no text width in the value coordinate space.
interface WidgetDecoration {
id: string;
type: 'widget';
offset: number;
create(): HTMLElement;
side?: 'before' | 'after';
key?: string; // keep the line DOM while the key is unchanged
layout?: 'inline' | 'block' | 'trailing';
accessible?: boolean; // expose the widget to assistive technology
}
api.setDecorations([
{
type: 'widget',
offset: 5,
side: 'after',
create() {
const badge = document.createElement('span');
badge.textContent = '!';
badge.title = 'Diagnostic available';
return badge;
},
},
]);
Without a key, create() runs whenever the line rebuilds, so return a new element each time.
With a key, unchanged lines keep their DOM; change the key when the widget's content changes.
layout defaults to inline; block widgets add to the line's height, and trailing widgets
reserve space at the logical line end. The default wrapper is decorative, so set
accessible: true for controls or meaningful content. Such controls take normal Tab navigation
instead of tab insertion.
FoldDecoration
Hides complete source lines while keeping their text and offsets in the value.
interface FoldDecoration {
id: string;
type: 'fold';
fromLine: number; // first hidden line, 1-based, inclusive
toLine: number; // last hidden line, 1-based, inclusive
reveal(): void; // called before a user edit or caret move enters the hidden lines
}
Most plugins create folds through FoldingActionController from @rcarls/rc-textarea/folding
rather than directly. The controller owns the accessible button and expansion state, not panel
content or syntax rules. Give each action a stable key, label, target, and gutterLine or
trailing offset. A target is a caller-owned panel element or { fromLine, toLine }. Render
panel content separately as an accessible block widget and change its widget key when that
content changes, and call the controller's clear() when document identity changes and in
destroy(). See Folding actions for the user-facing
behavior.
Plugin API reference
Full plugin API
interface RCTextareaPluginAPI {
// State
readonly host: HTMLElement;
readonly value: string;
readonly selectionStart: number;
readonly selectionEnd: number;
// Content language and direction
readonly contentDirection: 'ltr' | 'rtl';
readonly contentLang: string;
lineDirection(line: number): 'ltr' | 'rtl';
// Cursor
getCursorRect(): DOMRect | null;
getWordAtCursor(): { word: string; from: number; to: number } | null;
onCursorMove(callback: (selectionStart: number, selectionEnd: number) => void): () => void;
// Text mutation
insertText(text: string): void;
wrapSelection(prefix: string, suffix: string): void;
replaceSelection(text: string): void;
// Decorations
addDecoration(decoration: DecorationInput): string;
removeDecoration(id: string): void;
clearDecorations(): void;
setDecorations(decorations: DecorationInput[]): void;
getDecorations(): readonly Decoration[];
scheduleUpdate(): void;
// Stylesheets
adoptStyleSheet(sheetOrCssText: CSSStyleSheet | string): CSSStyleSheet;
removeStyleSheet(sheet: CSSStyleSheet): void;
// HTML / token bridges
parseDecorationsFromHtml(html: string): Omit<MarkDecoration, 'id'>[];
decorationsFromTokens(
tokens: Token[],
themeMap: Record<string, Omit<MarkDecoration, 'id' | 'type' | 'from' | 'to'>>,
): Omit<MarkDecoration, 'id'>[];
}
Cursor and selection
The plugin API exposes selection as plain-text offsets, not DOM ranges.
let unsubscribe: (() => void) | undefined;
editor.usePlugin({
mount(api) {
unsubscribe = api.onCursorMove((selectionStart, selectionEnd) => {
if (selectionStart !== selectionEnd) return;
const word = api.getWordAtCursor();
const rect = api.getCursorRect();
if (word && rect) showCompletion(word, rect);
});
},
destroy() {
unsubscribe?.();
},
});
getCursorRect() returns viewport coordinates, suitable for anchoring autocomplete panels or
hover tooltips. Place such a panel by contentDirection, or by lineDirection(line) for the
caret's 1-based line, rather than by the host's direction.
getWordAtCursor() segments the caret's line with Intl.Segmenter for contentLang. Accented
words, contractions such as don't, snake_case names and Japanese words each count as one
word; a cursor between two words returns the one after it. Without Intl.Segmenter, it scans
letters, digits and _.
Text mutation
insertText(), wrapSelection(), and replaceSelection() operate on the current selection.
They update the value, sync the slotted textarea, dispatch rc-textarea-change, and schedule a
render.
api.insertText('```\n\n```');
api.wrapSelection('**', '**'); // no-op when the selection is collapsed
api.replaceSelection('[replaced]');
Stylesheet injection
The editor, lines, marks, widgets, and diagnostics live inside the component's shadow root, so
document styles do not reach them. Inject decoration CSS with api.adoptStyleSheet(). Adopted
sheets are removed automatically when the plugin is unmounted.
editor.usePlugin({
mount(api) {
api.adoptStyleSheet(`
.keyword { color: var(--editor-syntax-keyword); font-weight: 600; }
.string { color: var(--editor-syntax-string); }
`);
},
});
Pass a shared CSSStyleSheet to reuse one sheet across editors:
const sharedSheet = new CSSStyleSheet();
sharedSheet.replaceSync('.keyword { color: rebeccapurple; }');
editor.usePlugin({
mount(api) {
api.adoptStyleSheet(sharedSheet);
},
});
Token bridge
decorationsFromTokens() converts flat token arrays from external tokenizers (lezer, shiki,
moo, etc.) into mark decorations. Token offsets must be absolute character indices.
interface Token {
from: number;
to: number;
type: string;
scopes?: string[];
}
editor.usePlugin({
update(value, api) {
const tokens = tokenizer.tokenize(value);
api.setDecorations(
api.decorationsFromTokens(tokens, {
keyword: { bold: true, color: 'var(--editor-syntax-keyword)' },
string: { color: 'var(--editor-syntax-string)' },
comment: { italic: true, color: 'var(--editor-syntax-comment)' },
}),
);
},
});
For ready-made Lezer, Unified, and Shiki adapters, install @rcarls/rc-textarea-adapters.
Helpers
matchPatternResults(value, patterns)
Run a TextPattern array against a string and get decoration objects without registering
patterns on the editor. Useful when a plugin needs to merge pattern results with other
decorations in a single setDecorations() call.
import { matchPatternResults } from '@rcarls/rc-textarea';
const patterns = [
{ id: 'kw-fn', pattern: /\bfunction\b/g, bold: true, className: 'keyword' },
{ id: 'kw-ret', pattern: /\breturn\b/g, bold: true, className: 'keyword' },
];
editor.usePlugin({
update(value, api) {
const { markDecorations, lineDecorations } = matchPatternResults(value, patterns);
api.setDecorations([...markDecorations, ...lineDecorations, ...lint(value)]);
},
});
createLineDecoratorPlugin(decorator, options?)
Wraps a LineDecoratorPlugin in a full RCTextareaPlugin. It injects the decorator's styles,
converts the line-relative mark offsets returned from decorateLine() to document offsets, and
handles optional watch subscriptions.
import { createLineDecoratorPlugin, type LineDecoratorPlugin } from '@rcarls/rc-textarea';
const keywordDecorator: LineDecoratorPlugin = {
styles: '.keyword { font-weight: 600; color: var(--editor-syntax-keyword); }',
decorateLine(line) {
const decorations = [];
for (const match of line.matchAll(/\bfunction\b/g)) {
decorations.push({
type: 'mark' as const,
from: match.index,
to: match.index + match[0].length,
className: 'keyword',
});
}
return decorations;
},
};
editor.usePlugin(createLineDecoratorPlugin(keywordDecorator));
extraDecorations merges whole-document results into the line decorator output:
editor.usePlugin(
createLineDecoratorPlugin(myLineDecorator, {
extraDecorations(value) {
return lint(value).map((d) => ({
type: 'line' as const,
line: d.line,
message: d.message,
messageClassName: 'error',
}));
},
}),
);
watch triggers api.scheduleUpdate() when external state changes:
editor.usePlugin(
createLineDecoratorPlugin(myLineDecorator, {
watch: [
(onChange) => {
window.addEventListener('theme-change', onChange);
return () => window.removeEventListener('theme-change', onChange);
},
],
}),
);
Recipes
highlight.js
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
hljs.registerLanguage('javascript', javascript);
editor.usePlugin({
mount(api) {
api.adoptStyleSheet(`
.hljs-keyword { color: var(--editor-syntax-keyword); font-weight: 600; }
.hljs-string { color: var(--editor-syntax-string); }
.hljs-comment { color: var(--editor-syntax-comment); font-style: italic; }
`);
},
highlight(value) {
return hljs.highlight(value, { language: 'javascript' }).value;
},
});
Prism.js
import Prism from 'prismjs';
import 'prismjs/components/prism-python';
editor.usePlugin({
mount(api) {
api.adoptStyleSheet(`
.token.keyword { color: var(--editor-syntax-keyword); font-weight: 600; }
.token.string { color: var(--editor-syntax-string); }
.token.comment { color: var(--editor-syntax-comment); font-style: italic; }
`);
},
highlight(value) {
return Prism.highlight(value, Prism.languages.python, 'python');
},
});
Merging HTML marks with other decorations
To combine HTML-derived marks with other decorations, parse the HTML yourself in update()
instead of returning it from highlight():
editor.usePlugin({
update(value, api) {
const html = highlight(value);
const syntax = api.parseDecorationsFromHtml(html);
const diagnostics = lint(value);
api.setDecorations([...syntax, ...diagnostics]);
},
});
Async plugin (WASM / web worker)
Stale results are automatically discarded when a newer render starts.
editor.usePlugin({
async update(value, api) {
const decs = await myWasmParser.decorate(value);
api.setDecorations(decs);
},
});
Read-only preview transform
// <rc-textarea><textarea aria-label="Preview" readonly></textarea></rc-textarea>
editor.usePlugin({
transform(value) {
return value.trimEnd();
},
update(value, api) {
api.setDecorations(parsePreviewDecorations(value));
},
});
Pattern with diagnostic message
editor.addPattern({
pattern: /\bFIXME\b/g,
bold: true,
color: 'var(--editor-diagnostic-error)',
underline: 'wavy',
createLineDecoration: () => ({
className: 'line-error',
message: 'Review before release',
messageClassName: 'error',
gutterContent: '!',
}),
});
Accessing the slotted textarea
editor.usePlugin({
mount(api) {
const textarea = api.host.querySelector('textarea');
if (textarea) console.log(textarea.name, textarea.maxLength);
},
});
Troubleshooting
Decorations disappear after large edits. Plugin decorations are remapped through edits, but
very large text changes clear mapped plugin decorations to avoid stale offsets. Recompute
plugin decorations in update() for durable results.
Cursor position looks different after decorations change. Selection is restored through
plain-text offsets. Widgets have no text width in the value, and new decorations can change the
visual shape around the same offset. Use getCursorRect() for positioning UI.
Styles do not apply to marks or lines. Decoration elements are in the shadow root. Use
api.adoptStyleSheet() rather than a document stylesheet.
HTML highlighting duplicates decorations. Returning a string from highlight() adds parsed
marks to existing plugin decorations. If you need exact replacement, do all work inside
update() and call api.setDecorations().