Skip to main content

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().