@rcarls/rc-textarea 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +1311 -0
  2. package/dist/custom-elements.json +2103 -0
  3. package/dist/demo.css +85 -0
  4. package/dist/demo.js +40 -0
  5. package/dist/line-actions-controller-BsM6DJ0b.js +2334 -0
  6. package/dist/line-actions-controller-BsM6DJ0b.js.map +1 -0
  7. package/dist/rc-textarea-define.js +10 -0
  8. package/dist/rc-textarea-define.js.map +1 -0
  9. package/dist/rc-textarea.js +8 -0
  10. package/dist/rc-textarea.js.map +1 -0
  11. package/dist/types/packages/rc-textarea/src/blots.d.ts +39 -0
  12. package/dist/types/packages/rc-textarea/src/decoration.d.ts +65 -0
  13. package/dist/types/packages/rc-textarea/src/decoration.test.d.ts +1 -0
  14. package/dist/types/packages/rc-textarea/src/define.d.ts +1 -0
  15. package/dist/types/packages/rc-textarea/src/document.d.ts +19 -0
  16. package/dist/types/packages/rc-textarea/src/index.d.ts +7 -0
  17. package/dist/types/packages/rc-textarea/src/line-actions-controller.d.ts +91 -0
  18. package/dist/types/packages/rc-textarea/src/line-decorator.d.ts +57 -0
  19. package/dist/types/packages/rc-textarea/src/line-decorator.test.d.ts +1 -0
  20. package/dist/types/packages/rc-textarea/src/pattern-matcher.d.ts +23 -0
  21. package/dist/types/packages/rc-textarea/src/pattern-matcher.test.d.ts +1 -0
  22. package/dist/types/packages/rc-textarea/src/rc-textarea.d.ts +194 -0
  23. package/dist/types/packages/rc-textarea/src/rc-textarea.styles.d.ts +1 -0
  24. package/dist/types/packages/rc-textarea/src/rc-textarea.test.d.ts +0 -0
  25. package/dist/types/packages/rc-textarea/src/selection.d.ts +39 -0
  26. package/dist/types/packages/rc-textarea/src/test-helpers.d.ts +18 -0
  27. package/dist/types/packages/rc-textarea/src/types.d.ts +394 -0
  28. package/package.json +68 -0
package/README.md ADDED
@@ -0,0 +1,1311 @@
1
+ # rc-textarea
2
+
3
+ A WAI-ARIA compliant enhanced textarea web component built with [Lit 3](https://lit.dev) and [Parchment](https://github.com/quilljs/parchment). Uses a `contenteditable` div as its editing surface, enabling inline visual formatting (bold, italic, color, underlines) that is impossible with a plain `<textarea>`.
4
+
5
+ **rc-textarea is not a rich text editor.** The underlying value is always plain text. Decorations are applied visually only — entirely through the JavaScript API, not through user action.
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ 1. [Key Capabilities](#key-capabilities)
12
+ 2. [Architecture](#architecture)
13
+ 3. [Installation](#installation)
14
+ 4. [Basic Usage](#basic-usage)
15
+ 5. [Attributes & Properties](#attributes--properties)
16
+ 6. [Value Property](#value-property)
17
+ 7. [Events](#events)
18
+ 8. [CSS Customization](#css-customization)
19
+ 9. [Plugin API](#plugin-api)
20
+ 10. [Decoration Types](#decoration-types)
21
+ 11. [Pattern API](#pattern-api)
22
+ 12. [Plugin Helpers](#plugin-helpers)
23
+ 13. [Selection API](#selection-api)
24
+ 14. [Decoration Lifecycle](#decoration-lifecycle)
25
+ 15. [Undo / Redo](#undo--redo)
26
+ 16. [Keyboard Behavior](#keyboard-behavior)
27
+ 17. [Form Integration](#form-integration)
28
+ 18. [Accessibility](#accessibility)
29
+ 19. [Performance Considerations](#performance-considerations)
30
+ 20. [Troubleshooting](#troubleshooting)
31
+
32
+ ---
33
+
34
+ ## Key Capabilities
35
+
36
+ - **Mixed inline formatting** — bold, italic, color, background, underline styles on arbitrary character ranges
37
+ - **Error-lens style line annotations** — end-of-line messages (like VS Code's error lens)
38
+ - **Inline widgets** — non-editable DOM elements inserted at any character offset
39
+ - **Regex pattern decorations** — auto-decorate text matching regular expressions
40
+ - **Plugin API** — imperative decoration control + highlight.js / prism.js HTML compatibility bridge
41
+ - **Line numbers, word wrap, auto-grow** — feature flags for common use cases
42
+ - **Progressive enhancement** — wraps a native `<textarea>` for form submission; the textarea is hidden, the `contenteditable` div is the interaction surface
43
+ - **Custom undo/redo** — DOM rebuilds invalidate the browser's native undo stack; the component maintains its own
44
+ - **Accessible by default** — `role="textbox"`, `aria-multiline="true"`, spellcheck disabled, focus management
45
+
46
+ ---
47
+
48
+ ## Architecture
49
+
50
+ ### DOM Structure
51
+
52
+ ```
53
+ rc-textarea (LitElement shadow host, delegatesFocus=true)
54
+ ├── #root (flex container)
55
+ │ ├── #gutter (line/list/custom gutter, optional)
56
+ │ │ └── #gutter-cells (container for .gutter-cell spans, one per line)
57
+ │ └── #editor-area (flex item, grows to fill)
58
+ │ ├── #editor (contenteditable div — Parchment ScrollBlot root)
59
+ │ │ └── .v2-line divs (one per logical line, V2BlockBlot)
60
+ │ │ ├── text nodes (plain text content)
61
+ │ │ ├── .v2-mark spans (V2InlineBlot — mark decorations)
62
+ │ │ ├── .v2-widget spans (V2WidgetBlot — inline widgets, contenteditable=false)
63
+ │ │ └── [data-message] attr (error-lens: used by ::after pseudo-element)
64
+ │ └── <slot> (lightDOM textarea — hidden, form-only)
65
+ ```
66
+
67
+ **Shadow DOM usage**: `delegatesFocus=true` ensures focus management works correctly. Line numbers are in the shadow DOM but read-only.
68
+
69
+ ### Editing Loop (Data Flow)
70
+
71
+ 1. **Browser handles edit** — User types/pastes/deletes text in the contenteditable div
72
+ 2. **`input` event fires** → `_onInput()` handler:
73
+ - Save DOM selection to plain-text offsets via `saveSelection()`
74
+ - Extract plain text via `extractEditorText()`
75
+ - Update `this._value`
76
+ - Dispatch `rc-textarea-change` event
77
+ - Map existing decorations through the text change via `mapDecorationsThroughChange()`
78
+ - Schedule RAF render pass
79
+ 3. **RAF render pass** → `_performRender()`:
80
+ - Extract plugin decorations
81
+ - Re-run patterns (rebuild all pattern decorations from regex)
82
+ - Ask plugin for new decorations (call `plugin.update()` or `plugin.highlight()`)
83
+ - Build `V2Document` from text + all decorations
84
+ - Tear down and fully rebuild the blot tree
85
+ - Restore DOM selection via `restoreSelection()`
86
+ - Update line number gutter if `lineNumbers=true`
87
+ 4. **Decorations stay in sync** — All existing decorations persist through edits (mapped to new offsets)
88
+
89
+ **Key insight for LLM agents**: The blot tree is **rebuilt on every render frame**. This is necessary because decoration changes require DOM restructuring. The undo stack stores plain-text values, not DOM snapshots.
90
+
91
+ ### Parchment Integration
92
+
93
+ - **`V2ScrollBlot`** wraps the `#editor` div and suppresses Parchment's `MutationObserver` (which would fight with our render loop)
94
+ - **`V2BlockBlot`** represents a single line (`.v2-line` div)
95
+ - **`V2InlineBlot`** represents a mark decoration (`.v2-mark` span)
96
+ - **`V2WidgetBlot`** represents an inline widget (`.v2-widget` span, `contenteditable=false`)
97
+ - **`V2Document`** class contains the build logic: `build(text, decorations) → BlotTree`
98
+
99
+ The blot tree is **immutable** — each render creates a new tree from scratch, then the DOM is replaced.
100
+
101
+ ### Cursor Management
102
+
103
+ Plain-text offsets are essential for:
104
+ - Storing selection state across DOM rebuilds
105
+ - Decoration `from`/`to` ranges
106
+ - Plugin API (selection, cursor position)
107
+ - Undo/redo stack
108
+
109
+ **`selection.ts` module**:
110
+ - `saveSelection()` — convert DOM range to plain-text offsets, skipping widget spans
111
+ - `restoreSelection()` — convert plain-text offsets back to DOM range after rebuild
112
+
113
+ ### File Organization
114
+
115
+ | File | Purpose |
116
+ |------|---------|
117
+ | `src/rc-textarea.ts` | Main LitElement: editing loop, plugin/pattern management, form wiring, undo/redo stack, gutter, event dispatch |
118
+ | `src/document.ts` | `V2Document` class — builds Parchment tree from text + decorations; `extractEditorText()` reverse operation |
119
+ | `src/blots.ts` | Parchment blot subclasses (`V2ScrollBlot`, `V2BlockBlot`, `V2InlineBlot`, `V2WidgetBlot`) + blot registry |
120
+ | `src/selection.ts` | `saveSelection()` / `restoreSelection()` for plain-text ↔ DOM range conversion |
121
+ | `src/decoration.ts` | `mapDecorationsThroughChange()` — map existing decorations through text edits; `isLargeChange()` heuristic |
122
+ | `src/pattern-matcher.ts` | `matchPatternResults()` — run `TextPattern` array against text, return mark + line decorations |
123
+ | `src/line-decorator.ts` | `createLineDecoratorPlugin()` factory — wraps per-line decoration logic in a full `RCTextareaPlugin` |
124
+ | `src/types.ts` | All exported TypeScript interfaces and utility types |
125
+ | `src/rc-textarea.styles.ts` | Component CSS (custom properties, parts, internal layout) |
126
+
127
+ ---
128
+
129
+ ## Installation
130
+
131
+ ```bash
132
+ npm install @rcarls/rc-textarea
133
+ # or
134
+ yarn add @rcarls/rc-textarea
135
+ ```
136
+
137
+ Import to auto-register the custom element:
138
+
139
+ ```ts
140
+ import '@rcarls/rc-textarea';
141
+ ```
142
+
143
+ Or import the class for typed access:
144
+
145
+ ```ts
146
+ import { RCTextarea } from '@rcarls/rc-textarea';
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Basic Usage
152
+
153
+ Slot a native `<textarea>` as the direct child. It is hidden from view and used only for form wiring.
154
+
155
+ ```html
156
+ <rc-textarea>
157
+ <textarea name="body" rows="10" placeholder="Start typing…"></textarea>
158
+ </rc-textarea>
159
+ ```
160
+
161
+ The component adopts the textarea's `name`, `required`, `disabled`, `maxlength`, `placeholder`, and initial `value`. Form submission reads the textarea's value, which is kept in sync by the component.
162
+
163
+ ### JavaScript access
164
+
165
+ ```ts
166
+ const editor = document.querySelector('rc-textarea');
167
+
168
+ // Read current plain text
169
+ console.log(editor.value);
170
+
171
+ // Set programmatically
172
+ editor.value = 'new content';
173
+
174
+ // Track changes
175
+ editor.addEventListener('rc-textarea-change', (e) => {
176
+ console.log('New value:', e.detail.value);
177
+ });
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Attributes & Properties
183
+
184
+ All attributes reflect to properties. Use attributes in HTML or properties in JS.
185
+
186
+ | Attribute | Property | Type | Default | Description |
187
+ |-----------|----------|------|---------|-------------|
188
+ | `line-numbers` | `lineNumbers` | `boolean` | `false` | Display line number gutter on the left |
189
+ | `list-numbers` | `listNumbers` | `boolean` | `false` | Display a numbered list gutter (skips blank lines, resets counter) |
190
+ | `gutter` | `gutter` | `boolean` | `false` | Show a gutter column without built-in content (plugins fill cells via `LineDecoration.gutterContent`) |
191
+ | `word-wrap` | `wordWrap` | `boolean` | `false` | Enable word wrapping (default: scroll horizontally) |
192
+ | `auto-grow` | `autoGrow` | `boolean` | `false` | Grow container height to fit content |
193
+ | `read-only` | `readOnly` | `boolean` | `false` | Disable text editing; still selectable |
194
+ | `label` | `label` | `string \| null` | `null` | Sets `aria-label` on the editor div |
195
+
196
+ ### Example
197
+
198
+ ```html
199
+ <rc-textarea
200
+ id="code-editor"
201
+ line-numbers
202
+ word-wrap
203
+ auto-grow
204
+ label="Code editor"
205
+ >
206
+ <textarea name="code" rows="20"></textarea>
207
+ </rc-textarea>
208
+ ```
209
+
210
+ ```ts
211
+ const editor = document.querySelector('#code-editor');
212
+ console.log(editor.lineNumbers); // true
213
+ console.log(editor.wordWrap); // true
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Value Property
219
+
220
+ ```ts
221
+ const editor = document.querySelector('rc-textarea');
222
+
223
+ // Read
224
+ const plainText = editor.value;
225
+
226
+ // Write — decorations are mapped through the change
227
+ editor.value = 'hello world';
228
+
229
+ // Write via textarea (if slotted)
230
+ const textarea = editor.querySelector('textarea');
231
+ textarea.value = 'test';
232
+ editor.value = textarea.value; // sync if needed
233
+ ```
234
+
235
+ Setting `value` programmatically:
236
+ 1. Updates `this._value` and the slotted textarea
237
+ 2. Dispatches `rc-textarea-change` event
238
+ 3. Schedules a RAF render pass
239
+ 4. Existing decorations are **mapped through the change** (shifted, clamped, or cleared)
240
+
241
+ ---
242
+
243
+ ## Events
244
+
245
+ All events bubble and are composed (cross shadow boundary).
246
+
247
+ | Event | Detail | Fires when |
248
+ |-------|--------|------------|
249
+ | `rc-textarea-change` | `{ value: string }` | Text changes (input, paste, undo/redo, programmatic `value` set) |
250
+ | `rc-textarea-focus` | (empty) | Editor receives focus |
251
+ | `rc-textarea-blur` | (empty) | Editor loses focus |
252
+ | `rc-textarea-select` | `{ selectionStart: number, selectionEnd: number }` | Selection changes (cursor move, click, keyboard nav) |
253
+
254
+ ### Example
255
+
256
+ ```ts
257
+ const editor = document.querySelector('rc-textarea');
258
+
259
+ editor.addEventListener('rc-textarea-change', (e) => {
260
+ console.log('Text changed to:', e.detail.value);
261
+ });
262
+
263
+ editor.addEventListener('rc-textarea-select', (e) => {
264
+ const { selectionStart, selectionEnd } = e.detail;
265
+ console.log(`Selection: ${selectionStart}–${selectionEnd}`);
266
+ });
267
+
268
+ editor.addEventListener('rc-textarea-focus', () => {
269
+ console.log('Editor focused');
270
+ });
271
+ ```
272
+
273
+ ---
274
+
275
+ ## CSS Customization
276
+
277
+ ### Custom Properties
278
+
279
+ Set on the host element or any ancestor to style the editor.
280
+
281
+ | Property | Default | Description |
282
+ |----------|---------|-------------|
283
+ | `--rc-textarea-font-family` | `monospace` | Font family for text and line numbers |
284
+ | `--rc-textarea-font-size` | `1em` | Base font size |
285
+ | `--rc-textarea-line-height` | `1.5` | Line height affects gutter alignment |
286
+ | `--rc-textarea-padding` | `0.5em` | Inner padding of editor area |
287
+ | `--rc-textarea-background` | `Field` | Editor background color (system color keyword) |
288
+ | `--rc-textarea-color` | `FieldText` | Text color (system color keyword) |
289
+ | `--rc-textarea-caret-color` | (auto) | Cursor/caret color |
290
+ | `--rc-textarea-border` | `1px solid ButtonBorder` | Editor border (system color keyword) |
291
+ | `--rc-textarea-border-radius` | `2px` | Corner rounding |
292
+ | `--rc-textarea-focus-outline` | `2px solid AccentColor` | Focus ring (system color keyword) |
293
+ | `--rc-textarea-active-line-bg` | `transparent` | Background of the line containing the cursor |
294
+ | `--rc-textarea-gutter-color` | `GrayText` | Line number text color |
295
+ | `--rc-textarea-gutter-bg` | `Canvas` | Gutter background |
296
+ | `--rc-textarea-gutter-border` | `1px solid ButtonBorder` | Gutter right border |
297
+ | `--rc-textarea-gutter-padding-inline-end` | `0.75em` | Gap between gutter numbers and editor content |
298
+
299
+ ### Example
300
+
301
+ ```css
302
+ rc-textarea {
303
+ --rc-textarea-font-family: 'Fira Code', monospace;
304
+ --rc-textarea-font-size: 13px;
305
+ --rc-textarea-line-height: 1.6;
306
+ --rc-textarea-background: #1e1e2e;
307
+ --rc-textarea-color: #cdd6f4;
308
+ --rc-textarea-caret-color: #89b4fa;
309
+ --rc-textarea-border: 1px solid #313244;
310
+ --rc-textarea-border-radius: 4px;
311
+ }
312
+ ```
313
+
314
+ ### CSS Parts
315
+
316
+ Use `::part()` pseudo-element for advanced styling.
317
+
318
+ | Part | Element | Supports |
319
+ |------|---------|----------|
320
+ | `root` | Outer flex container | All CSS |
321
+ | `gutter` | Gutter outer div | All CSS |
322
+ | `gutter-cells` | Inner container holding `.gutter-cell` spans | All CSS |
323
+ | `editor-area` | Container wrapping editor + slot | All CSS |
324
+ | `editor` | The `contenteditable` div | All CSS |
325
+
326
+ ### Example
327
+
328
+ ```css
329
+ rc-textarea::part(editor) {
330
+ border-radius: 4px;
331
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
332
+ }
333
+
334
+ rc-textarea::part(gutter) {
335
+ background: #f5f5f5;
336
+ font-size: 0.9em;
337
+ }
338
+ ```
339
+
340
+ ### Mark decoration styling
341
+
342
+ Inline decorations are applied to `.v2-mark` spans. Inline styles handle standard properties (bold, italic, color, etc.). For custom classes:
343
+
344
+ ```ts
345
+ let api: RCTextareaPluginAPI;
346
+ editor.usePlugin({
347
+ mount(a) { api = a; },
348
+ update() {},
349
+ });
350
+
351
+ // Add decorations with a custom class
352
+ api.setDecorations([
353
+ { type: 'mark', from: 5, to: 10, className: 'my-highlight' }
354
+ ]);
355
+
356
+ // Style it in an adopted stylesheet
357
+ api.adoptStyleSheet(`
358
+ .v2-mark.my-highlight { background: yellow; }
359
+ `);
360
+ ```
361
+
362
+ ---
363
+
364
+ ## Plugin API
365
+
366
+ Plugins are the primary mechanism for applying decorations. A plugin receives a `RCTextareaPluginAPI` during `mount()` — **save this reference** to set decorations from outside the plugin lifecycle.
367
+
368
+ ### Interface Overview
369
+
370
+ ```ts
371
+ interface RCTextareaPlugin {
372
+ /** Called once when the plugin is registered. Store `api` here for external use. */
373
+ mount?(api: RCTextareaPluginAPI): void;
374
+ /** Called when the plugin is replaced or the element disconnects. */
375
+ destroy?(): void;
376
+ /**
377
+ * Display-layer value transform — called before `update`/`highlight` in
378
+ * read-only mode. Return a non-null string to substitute it as the rendered
379
+ * text. The underlying `element.value` is never modified.
380
+ */
381
+ transform?(value: string, api: RCTextareaPluginAPI): string | null | void;
382
+ /** Imperative decoration API — called on each value change. */
383
+ update?(value: string, api: RCTextareaPluginAPI): void | Promise<void>;
384
+ /**
385
+ * HTML-based compat — called on each value change.
386
+ * Return an HTML string (e.g. from hljs/prism); it will be parsed into
387
+ * mark decorations via `api.decorationsFromHtml()`.
388
+ */
389
+ highlight?(value: string, api: RCTextareaPluginAPI):
390
+ string | null | void | Promise<string | null | void>;
391
+ }
392
+ ```
393
+
394
+ At least one of `update` or `highlight` must be provided. `transform` is only
395
+ called in read-only mode and is typically used to substitute scaled or formatted
396
+ display text while preserving the raw underlying value.
397
+
398
+ ### PluginAPI Methods
399
+
400
+ ```ts
401
+ interface RCTextareaPluginAPI {
402
+ readonly host: HTMLElement; // rc-textarea element
403
+ readonly value: string; // Current plain text
404
+ readonly selectionStart: number; // Normalized selection start (≤ selectionEnd)
405
+ readonly selectionEnd: number; // Normalized selection end (≥ selectionStart)
406
+
407
+ getCursorRect(): DOMRect | null; // Cursor position in viewport coords (or null if not focused)
408
+ getWordAtCursor(): { word: string; from: number; to: number } | null;
409
+ onCursorMove(cb: (start: number, end: number) => void): () => void;
410
+
411
+ addDecoration(d: DecorationInput): string; // Add single decoration, return ID
412
+ removeDecoration(id: string): void; // Remove by ID
413
+ clearDecorations(): void; // Remove all plugin decorations
414
+ setDecorations(decorations: DecorationInput[]): void; // Replace all with new set
415
+
416
+ scheduleUpdate(): void; // Trigger render outside input events
417
+
418
+ adoptStyleSheet(sheetOrCssText: CSSStyleSheet | string): CSSStyleSheet;
419
+ removeStyleSheet(sheet: CSSStyleSheet): void;
420
+ decorationsFromHtml(html: string): Omit<MarkDecoration, 'id'>[];
421
+ }
422
+ ```
423
+
424
+ ### Mounting and Lifecycle
425
+
426
+ ```ts
427
+ const editor = document.querySelector('rc-textarea');
428
+
429
+ let api: RCTextareaPluginAPI;
430
+
431
+ editor.usePlugin({
432
+ mount(a) {
433
+ api = a; // Save reference for external use
434
+ console.log('Plugin mounted');
435
+ },
436
+ update(value, a) {
437
+ // Called on each value change
438
+ const decorations = parseText(value);
439
+ a.setDecorations(decorations);
440
+ },
441
+ destroy() {
442
+ console.log('Plugin unmounting');
443
+ },
444
+ });
445
+
446
+ // From outside plugin lifecycle, using saved reference
447
+ api.setDecorations([
448
+ { type: 'mark', from: 0, to: 5, bold: true }
449
+ ]);
450
+ api.scheduleUpdate();
451
+ ```
452
+
453
+ ### Example 1: Synchronous Imperative Plugin
454
+
455
+ Manually find patterns and apply decorations:
456
+
457
+ ```ts
458
+ const markdownLitePlugin = {
459
+ update(value, api) {
460
+ const decorations = [];
461
+
462
+ // Bold: **text**
463
+ const boldRe = /\*\*(.+?)\*\*/g;
464
+ let m;
465
+ while ((m = boldRe.exec(value)) !== null) {
466
+ decorations.push({
467
+ type: 'mark',
468
+ from: m.index,
469
+ to: m.index + m[0].length,
470
+ bold: true
471
+ });
472
+ }
473
+
474
+ // Italic: *text*
475
+ const italicRe = /\*([^*]+)\*/g;
476
+ while ((m = italicRe.exec(value)) !== null) {
477
+ decorations.push({
478
+ type: 'mark',
479
+ from: m.index,
480
+ to: m.index + m[0].length,
481
+ italic: true
482
+ });
483
+ }
484
+
485
+ api.setDecorations(decorations);
486
+ },
487
+ };
488
+
489
+ editor.usePlugin(markdownLitePlugin);
490
+ ```
491
+
492
+ ### Example 2: Asynchronous Plugin (WASM / Web Worker)
493
+
494
+ ```ts
495
+ editor.usePlugin({
496
+ async update(value, api) {
497
+ const decorations = await myWasmParser.tokenize(value);
498
+ api.setDecorations(decorations);
499
+ },
500
+ });
501
+ ```
502
+
503
+ Stale results are automatically discarded if a newer render starts before the promise resolves.
504
+
505
+ ### Example 3: highlight.js Integration
506
+
507
+ ```ts
508
+ import hljs from 'highlight.js/lib/core';
509
+ import javascript from 'highlight.js/lib/languages/javascript';
510
+ hljs.registerLanguage('javascript', javascript);
511
+
512
+ editor.usePlugin({
513
+ mount(api) {
514
+ // Inject theme CSS into shadow root (hljs classes live inside)
515
+ api.adoptStyleSheet(`
516
+ .hljs-keyword { color: #cba6f7; }
517
+ .hljs-string { color: #a6e3a1; }
518
+ .hljs-number { color: #fab387; }
519
+ .hljs-comment { color: #6c7086; font-style: italic; }
520
+ .hljs-title { color: #89b4fa; font-weight: bold; }
521
+ .hljs-built_in { color: #89dceb; }
522
+ .hljs-literal { color: #f5c2e7; }
523
+ `);
524
+ },
525
+ highlight(value, api) {
526
+ // hljs.highlight returns HTML with span wrapping syntax tokens
527
+ const { value: html } = hljs.highlight(value, { language: 'javascript' });
528
+ // Convert HTML token markup to decorations
529
+ api.setDecorations(api.decorationsFromHtml(html));
530
+ },
531
+ });
532
+ ```
533
+
534
+ **Important**: hljs token class spans live inside the shadow root. Light-DOM stylesheets cannot pierce the shadow boundary, so theme CSS must be injected via `api.adoptStyleSheet()`.
535
+
536
+ ### Example 4: Prism.js Integration
537
+
538
+ ```ts
539
+ import Prism from 'prismjs';
540
+ import 'prismjs/components/prism-python';
541
+
542
+ editor.usePlugin({
543
+ mount(api) {
544
+ api.adoptStyleSheet(`
545
+ .token.keyword { color: #66d9ef; font-weight: bold; }
546
+ .token.string { color: #e6db74; }
547
+ .token.number { color: #ae81ff; }
548
+ .token.comment { color: #75715e; font-style: italic; }
549
+ .token.function { color: #a1efe4; }
550
+ `);
551
+ },
552
+ highlight(value, api) {
553
+ const html = Prism.highlight(value, Prism.languages.python, 'python');
554
+ api.setDecorations(api.decorationsFromHtml(html));
555
+ },
556
+ });
557
+ ```
558
+
559
+ ### Switching / Removing Plugins
560
+
561
+ Only one plugin can be active at a time. Calling `usePlugin()` with a new plugin calls `destroy()` on the previous one.
562
+
563
+ ```ts
564
+ editor.usePlugin(newPlugin); // previous plugin is destroyed
565
+ editor.removePlugin(); // current plugin destroyed, decorations cleared
566
+ ```
567
+
568
+ ### Accessing Slotted Textarea
569
+
570
+ Plugins can read the textarea to access its attributes:
571
+
572
+ ```ts
573
+ editor.usePlugin({
574
+ mount(api) {
575
+ const textarea = api.host.querySelector('textarea');
576
+ if (textarea) {
577
+ console.log('Form name:', textarea.name);
578
+ console.log('Max length:', textarea.maxLength);
579
+ }
580
+ },
581
+ });
582
+ ```
583
+
584
+ ---
585
+
586
+ ## Decoration Types
587
+
588
+ Decorations are objects describing visual changes. All decorations are immutable after creation (API methods return new IDs or replace entire sets).
589
+
590
+ ### MarkDecoration — styled character range
591
+
592
+ A visual style applied to a contiguous range of characters. Inline styles are rendered directly; `className` adds an extra CSS class for complex styling.
593
+
594
+ ```ts
595
+ interface MarkDecoration {
596
+ id: string; // Auto-assigned — omit when passing to API
597
+ type: 'mark';
598
+ from: number; // Inclusive start (0-based character offset into plain text)
599
+ to: number; // Exclusive end
600
+ className?: string; // CSS class added to the span
601
+ bold?: boolean; // Inline: font-weight: bold
602
+ italic?: boolean; // Inline: font-style: italic
603
+ color?: string; // Inline: color (CSS color value)
604
+ background?: string; // Inline: background-color
605
+ underline?: 'solid' | 'wavy' | 'dotted' | 'dashed'; // text-decoration-style
606
+ underlineColor?: string; // text-decoration-color
607
+ attributes?: Record<string, string>; // Extra HTML attributes (data-*, title, etc.)
608
+ }
609
+ ```
610
+
611
+ ### LineDecoration — whole-line styling + error-lens annotation
612
+
613
+ Applied to an entire logical line. Adds a CSS class to the line div and optionally renders an error-lens message at the end.
614
+
615
+ ```ts
616
+ interface LineDecoration {
617
+ id: string;
618
+ type: 'line';
619
+ line: number; // 1-based logical line number
620
+ className?: string; // CSS class on the .v2-line div
621
+ message?: string; // Error-lens text (rendered via ::after, not selectable)
622
+ messageClassName?: string; // Space-separated class(es) set as data-message-class
623
+ attributes?: Record<string, string>;
624
+ gutterContent?: string | null; // Override gutter cell text for this line:
625
+ // string — custom label (e.g. "!", "▶")
626
+ // null — force empty (suppress built-in content)
627
+ // omitted — use the built-in mode default
628
+ }
629
+ ```
630
+
631
+ Error-lens messages are rendered via CSS `::after` pseudo-element — they do not appear in selection or clipboard.
632
+
633
+ ```css
634
+ /* in an adopted stylesheet */
635
+ .v2-line[data-message]::after {
636
+ content: attr(data-message);
637
+ margin-left: 1em;
638
+ color: #f38ba8;
639
+ font-style: italic;
640
+ }
641
+ ```
642
+
643
+ ### WidgetDecoration — non-editable inline element
644
+
645
+ A DOM element inserted at a character offset. Widgets are purely visual and do not appear in the plain text value.
646
+
647
+ ```ts
648
+ interface WidgetDecoration {
649
+ id: string;
650
+ type: 'widget';
651
+ offset: number; // Character offset — widget placed before/after this position
652
+ create(): HTMLElement; // Factory called each render — must return a **new** element
653
+ side?: 'before' | 'after'; // Placement relative to character. Default: 'before'
654
+ }
655
+ ```
656
+
657
+ **Important**: `create()` is called on every render frame. Return a new element each time; do not reuse the same DOM node.
658
+
659
+ ```ts
660
+ api.setDecorations([
661
+ {
662
+ type: 'widget',
663
+ offset: 5,
664
+ create() {
665
+ const el = document.createElement('span');
666
+ el.textContent = '👉';
667
+ el.style.color = '#fab387';
668
+ return el;
669
+ },
670
+ },
671
+ ]);
672
+ ```
673
+
674
+ ### DecorationInput (for API calls)
675
+
676
+ When calling `api.addDecoration()`, `api.setDecorations()`, etc., omit the `id` field:
677
+
678
+ ```ts
679
+ type DecorationInput =
680
+ | Omit<MarkDecoration, 'id'>
681
+ | Omit<LineDecoration, 'id'>
682
+ | Omit<WidgetDecoration, 'id'>;
683
+ ```
684
+
685
+ ---
686
+
687
+ ## Pattern API
688
+
689
+ Patterns automatically apply decorations to all regex matches on every value change, without manual event handling.
690
+
691
+ ### Adding and Removing Patterns
692
+
693
+ ```ts
694
+ const editor = document.querySelector('rc-textarea');
695
+
696
+ // Boolean pattern — bold + orange all "TODO" occurrences
697
+ const patternId = editor.addPattern({
698
+ pattern: /\bTODO\b/g,
699
+ bold: true,
700
+ color: '#fab387',
701
+ });
702
+
703
+ // Remove a specific pattern
704
+ editor.removePattern(patternId);
705
+
706
+ // Remove all patterns
707
+ editor.clearPatterns();
708
+ ```
709
+
710
+ ### TextPattern Interface
711
+
712
+ ```ts
713
+ interface TextPattern {
714
+ id: string; // Auto-assigned — omit when calling addPattern()
715
+ pattern: RegExp; // Global flag is added automatically if missing
716
+ className?: string;
717
+ bold?: boolean;
718
+ italic?: boolean;
719
+ color?: string;
720
+ background?: string;
721
+ underline?: 'solid' | 'wavy' | 'dotted' | 'dashed';
722
+ underlineColor?: string;
723
+ attributes?: Record<string, string>;
724
+
725
+ /**
726
+ * Per-named-capture-group styles. When set, one MarkDecoration is emitted
727
+ * per named group instead of one for the whole match. The 'd' flag
728
+ * (indices) is added to the pattern automatically.
729
+ * Unmatched optional groups are silently skipped.
730
+ */
731
+ captureGroups?: Record<string, MarkDecorationStyle>;
732
+
733
+ // Callback to generate a LineDecoration for matching lines
734
+ createLineDecoration?: (match: RegExpMatchArray) =>
735
+ | Omit<LineDecoration, 'id' | 'type' | 'line'>
736
+ | null;
737
+ }
738
+
739
+ /** Subset of MarkDecoration properties used for styling (no id / from / to). */
740
+ type MarkDecorationStyle = Pick<MarkDecoration,
741
+ 'className' | 'bold' | 'italic' | 'color' | 'background' |
742
+ 'underline' | 'underlineColor' | 'attributes'
743
+ >;
744
+ ```
745
+
746
+ ### Example: Simple Pattern
747
+
748
+ ```ts
749
+ // Red wavy underline for "FIXME"
750
+ editor.addPattern({
751
+ pattern: /\bFIXME\b/g,
752
+ color: '#f38ba8',
753
+ underline: 'wavy',
754
+ underlineColor: '#f38ba8',
755
+ });
756
+ ```
757
+
758
+ ### Example: Pattern with Error-Lens Annotation
759
+
760
+ ```ts
761
+ editor.addPattern({
762
+ pattern: /\bFIXME\b/g,
763
+ bold: true,
764
+ color: '#f38ba8',
765
+ underline: 'wavy',
766
+ createLineDecoration: (match) => ({
767
+ className: 'fixme-line',
768
+ message: 'Review before release',
769
+ messageClassName: 'fixme-message',
770
+ }),
771
+ });
772
+ ```
773
+
774
+ Then style the message:
775
+
776
+ ```ts
777
+ api.adoptStyleSheet(`
778
+ .v2-line[data-message-class~="fixme-message"]::after {
779
+ color: #f38ba8;
780
+ font-weight: bold;
781
+ }
782
+ `);
783
+ ```
784
+
785
+ ### Example: Multi-line Regex (JavaScript comments)
786
+
787
+ ```ts
788
+ editor.addPattern({
789
+ pattern: /\/\*[\s\S]*?\*\//g,
790
+ italic: true,
791
+ color: '#6c7086',
792
+ createLineDecoration: () => ({
793
+ message: '(comment)',
794
+ messageClassName: 'comment-marker',
795
+ }),
796
+ });
797
+ ```
798
+
799
+ ### Example: Named Capture Groups (`captureGroups`)
800
+
801
+ Use `captureGroups` to style different parts of a match independently without
802
+ manual offset arithmetic. One `MarkDecoration` is emitted per named group;
803
+ unmatched optional groups are skipped.
804
+
805
+ ```ts
806
+ // key: value lines — key in purple, value in green
807
+ editor.addPattern({
808
+ pattern: /^(?<key>\w[\w-]*):\s*(?<value>.+)$/gm,
809
+ captureGroups: {
810
+ key: { bold: true, color: '#c792ea' },
811
+ value: { color: '#c3e88d' },
812
+ },
813
+ });
814
+ ```
815
+
816
+ ```ts
817
+ // Ingredient lines — quantity/measure bold, prep text muted
818
+ editor.addPattern({
819
+ pattern: /^(?<qty>[\d\s\/\u215B-\u215E]+\s+\w+)\s+(?<name>[^,]+?)(?<prep>,\s+.+)?$/gm,
820
+ captureGroups: {
821
+ qty: { bold: true },
822
+ name: { bold: true, color: 'var(--color-primary)' },
823
+ prep: { italic: true, color: 'var(--color-text-muted)' },
824
+ },
825
+ });
826
+ ```
827
+
828
+ ---
829
+
830
+ ## Plugin Helpers
831
+
832
+ Two utilities are exported from the package to reduce plugin boilerplate.
833
+
834
+ ### `matchPatternResults(value, patterns)`
835
+
836
+ Run a `TextPattern` array against `value` and return all matches as
837
+ decoration objects — without registering the patterns on the editor element.
838
+ Useful when a plugin needs to combine pattern-matched decorations with custom
839
+ decorations in a single `api.setDecorations()` call.
840
+
841
+ ```ts
842
+ import { matchPatternResults } from '@rcarls/rc-textarea';
843
+
844
+ const KEYWORD_PATTERNS = [
845
+ { id: 'kw-function', pattern: /\bfunction\b/g, bold: true, color: '#c792ea' },
846
+ { id: 'kw-return', pattern: /\breturn\b/g, bold: true, color: '#89ddff' },
847
+ ];
848
+
849
+ editor.usePlugin({
850
+ update(value, api) {
851
+ const { markDecorations, lineDecorations } =
852
+ matchPatternResults(value, KEYWORD_PATTERNS);
853
+
854
+ // Merge with custom diagnostics from a parser
855
+ const diagnostics = myParser.lint(value);
856
+
857
+ api.setDecorations([
858
+ ...markDecorations,
859
+ ...lineDecorations,
860
+ ...diagnostics,
861
+ ]);
862
+ },
863
+ });
864
+ ```
865
+
866
+ ### `createLineDecoratorPlugin(decorator, options?)`
867
+
868
+ Factory that wraps a `LineDecoratorPlugin` in a full `RCTextareaPlugin`. It handles:
869
+
870
+ - CSS injection via `api.adoptStyleSheet` on `mount()`
871
+ - `lineStart` offset bookkeeping — `decorateLine()` works with **line-relative** offsets (0 = start of the line), not absolute document offsets
872
+ - Optional `watch` subscriptions that call `api.scheduleUpdate()` when external values change (framework-agnostic)
873
+ - Cleanup of subscribers on `destroy()`
874
+
875
+ ```ts
876
+ import { createLineDecoratorPlugin } from '@rcarls/rc-textarea';
877
+ import type { LineDecoratorPlugin } from '@rcarls/rc-textarea';
878
+
879
+ const KEYWORD_CSS = '.kw { font-weight: bold; color: #c792ea; }';
880
+
881
+ const keywordDecorator: LineDecoratorPlugin = {
882
+ styles: KEYWORD_CSS,
883
+ decorateLine(line) {
884
+ const results = [];
885
+ for (const m of line.matchAll(/\bfunction\b/g)) {
886
+ results.push({
887
+ type: 'mark' as const,
888
+ from: m.index!,
889
+ to: m.index! + m[0].length,
890
+ className: 'kw',
891
+ });
892
+ }
893
+ return results;
894
+ },
895
+ };
896
+
897
+ editor.usePlugin(createLineDecoratorPlugin(keywordDecorator));
898
+ ```
899
+
900
+ #### With `extraDecorations` (merging whole-document results)
901
+
902
+ ```ts
903
+ editor.usePlugin(createLineDecoratorPlugin(
904
+ myLineDecorator,
905
+ {
906
+ extraDecorations: (value) => {
907
+ // e.g. whole-document diagnostics from a parser
908
+ return myParser.lint(value).map(d => ({
909
+ type: 'line' as const,
910
+ line: d.line,
911
+ message: d.message,
912
+ messageClassName: 'diagnostic-error',
913
+ }));
914
+ },
915
+ },
916
+ ));
917
+ ```
918
+
919
+ #### With `watch` (react to external signal changes)
920
+
921
+ `watch` is an array of subscriber setup functions — each receives an `onChange`
922
+ callback and may return an optional cleanup function. This is
923
+ intentionally framework-agnostic.
924
+
925
+ ```ts
926
+ // Vanilla JS: subscribe to a custom event
927
+ editor.usePlugin(createLineDecoratorPlugin(
928
+ myDecorator,
929
+ {
930
+ watch: [
931
+ (onChange) => {
932
+ window.addEventListener('theme-change', onChange);
933
+ return () => window.removeEventListener('theme-change', onChange);
934
+ },
935
+ ],
936
+ },
937
+ ));
938
+
939
+ // Solid.js: pass reactive signals
940
+ import { createEffect, on } from 'solid-js';
941
+
942
+ editor.usePlugin(createLineDecoratorPlugin(
943
+ myDecorator,
944
+ {
945
+ watch: [
946
+ (cb) => createEffect(on(mySignal, cb, { defer: true })),
947
+ ],
948
+ },
949
+ ));
950
+ ```
951
+
952
+ ---
953
+
954
+ ## Selection API
955
+
956
+ The component tracks the current selection as plain-text offsets (not DOM nodes or ranges).
957
+
958
+ ### Getting Selection
959
+
960
+ ```ts
961
+ interface RCTextareaPluginAPI {
962
+ readonly selectionStart: number;
963
+ readonly selectionEnd: number;
964
+
965
+ getCursorRect(): DOMRect | null;
966
+ getWordAtCursor(): { word: string; from: number; to: number } | null;
967
+ onCursorMove(callback: (start: number, end: number) => void): () => void;
968
+ }
969
+ ```
970
+
971
+ ### Example: Tracking Cursor Moves
972
+
973
+ ```ts
974
+ let unsub: () => void;
975
+
976
+ editor.usePlugin({
977
+ mount(api) {
978
+ unsub = api.onCursorMove((start, end) => {
979
+ if (start === end) {
980
+ // Cursor is collapsed (no selection)
981
+ const word = api.getWordAtCursor();
982
+ if (word) {
983
+ console.log(`Cursor on word: "${word.word}" at ${word.from}–${word.to}`);
984
+ }
985
+ } else {
986
+ // Selection active
987
+ console.log(`Selection: ${start}–${end}`);
988
+ }
989
+ });
990
+ },
991
+ destroy() {
992
+ unsub?.();
993
+ },
994
+ });
995
+ ```
996
+
997
+ ### Example: Anchoring Autocomplete Popup
998
+
999
+ ```ts
1000
+ editor.usePlugin({
1001
+ mount(api) {
1002
+ const unsub = api.onCursorMove(() => {
1003
+ const rect = api.getCursorRect();
1004
+ if (rect) {
1005
+ const popup = document.querySelector('#autocomplete');
1006
+ popup.style.left = rect.left + 'px';
1007
+ popup.style.top = (rect.top + rect.height) + 'px';
1008
+ }
1009
+ });
1010
+ return () => unsub?.();
1011
+ },
1012
+ });
1013
+ ```
1014
+
1015
+ ---
1016
+
1017
+ ## Decoration Lifecycle
1018
+
1019
+ Decorations undergo transformation as the text changes:
1020
+
1021
+ ### Mapping Decorations Through Changes
1022
+
1023
+ When text is edited, existing plugin decorations are automatically adjusted:
1024
+
1025
+ - **Before the edit region**: start/end offsets unchanged
1026
+ - **After the edit region**: start/end offsets shifted by the character delta
1027
+ - **Overlapping the edit region**: start/end clamped to edit boundaries
1028
+ - **Zero-width after clamping**: decoration is dropped
1029
+
1030
+ ### Large Change Heuristic
1031
+
1032
+ If a change is detected as **"large"** (heuristic: `changeSize > 50 chars AND changeSize > 50% of document`), all plugin decorations are cleared to avoid mapping errors. This handles:
1033
+
1034
+ - Paste of large text blocks
1035
+ - Select-all + type
1036
+ - Programmatic `value` set with very different content
1037
+
1038
+ Pattern decorations are **always** fully recomputed on each change (regex is re-run).
1039
+
1040
+ ### Architecture Note for Agents
1041
+
1042
+ Decoration mapping occurs in `mapDecorationsThroughChange()` (in `decoration.ts`). The function:
1043
+ 1. Calls `findEdit()` to locate insertion/deletion boundaries
1044
+ 2. Applies geometry transformation to each decoration
1045
+ 3. Returns a new decoration map (old map is not mutated)
1046
+
1047
+ ---
1048
+
1049
+ ## Undo / Redo
1050
+
1051
+ The component maintains its own undo/redo stack because DOM rebuilds (which happen on every render frame) invalidate the browser's native contenteditable undo history.
1052
+
1053
+ ### Keyboard Shortcuts
1054
+
1055
+ | Key | Action |
1056
+ |-----|--------|
1057
+ | `Ctrl+Z` / `Cmd+Z` | Undo |
1058
+ | `Ctrl+Y` / `Cmd+Y` / `Ctrl+Shift+Z` | Redo |
1059
+
1060
+ ### Stack Details
1061
+
1062
+ - **Capacity**: 100 entries (MAX_UNDO)
1063
+ - **Stored per entry**: plain text value + cursor position (anchorOffset, focusOffset)
1064
+ - **Decorations**: not stored; recomputed from the restored value
1065
+ - **Granularity**: one entry per input event (or scheduled update)
1066
+
1067
+ ### Architecture
1068
+
1069
+ The undo stack is stored in:
1070
+ ```ts
1071
+ private _undoStack: UndoEntry[] = [];
1072
+ private _undoIndex = -1; // Current position in stack
1073
+ ```
1074
+
1075
+ Each entry is created when text changes via input event. The stack is pruned to MAX_UNDO when full.
1076
+
1077
+ ---
1078
+
1079
+ ## Keyboard Behavior
1080
+
1081
+ | Key | Behavior |
1082
+ |-----|----------|
1083
+ | `Tab` | Insert `\t` character (does not move focus) |
1084
+ | `Ctrl/Cmd+Z` | Undo |
1085
+ | `Ctrl/Cmd+Y` / `Ctrl/Cmd+Shift+Z` | Redo |
1086
+ | `Paste` | HTML/rich text is stripped; only plain text is inserted |
1087
+ | `Enter` | Insert line break (`\n`) |
1088
+
1089
+ ### Paste Handling
1090
+
1091
+ The component uses `input` event to detect and normalize pasted content. HTML markup is discarded; only plain text characters are inserted.
1092
+
1093
+ ---
1094
+
1095
+ ## Form Integration
1096
+
1097
+ The slotted `<textarea>` participates in form submission normally:
1098
+
1099
+ 1. **Hidden visually** — inline styles: `position: absolute; left: -9999px; opacity: 0; clip: rect(0 0 0 0)`
1100
+ 2. **DOM remains** — slotted at the light DOM so form traversal finds it
1101
+ 3. **Value synced** — component keeps textarea's value in sync with `this._value`
1102
+ 4. **Attributes adopted** — `name`, `required`, `disabled`, `maxlength`, `placeholder` are read from textarea on mount
1103
+ 5. **Submission** — form submission reads textarea's value directly
1104
+
1105
+ ### Example
1106
+
1107
+ ```html
1108
+ <form id="myform">
1109
+ <rc-textarea>
1110
+ <textarea
1111
+ name="body"
1112
+ required
1113
+ maxlength="5000"
1114
+ placeholder="Enter your message…"
1115
+ ></textarea>
1116
+ </rc-textarea>
1117
+ <button type="submit">Send</button>
1118
+ </form>
1119
+ ```
1120
+
1121
+ ```ts
1122
+ document.getElementById('myform').addEventListener('submit', (e) => {
1123
+ e.preventDefault();
1124
+ const formData = new FormData(e.target);
1125
+ console.log(formData.get('body')); // Plain text value
1126
+ });
1127
+ ```
1128
+
1129
+ ---
1130
+
1131
+ ## Accessibility
1132
+
1133
+ The component is designed with accessibility in mind:
1134
+
1135
+ ### ARIA Roles & Attributes
1136
+
1137
+ - **`role="textbox"`** — identifies the editor as a text input
1138
+ - **`aria-multiline="true"`** — indicates multiline text support
1139
+ - **`aria-label`** — set via the `label` attribute (optional)
1140
+ - **`delegatesFocus=true`** — shadow DOM focus is delegated to the editable region
1141
+
1142
+ ### Keyboard Navigation
1143
+
1144
+ - Tab and Shift+Tab focus/blur the editor normally
1145
+ - Arrow keys, Home/End, Ctrl+Arrow, etc. work natively
1146
+ - Screen readers can read selected text and cursor position
1147
+
1148
+ ### Visual Accessibility
1149
+
1150
+ - High contrast by default (system colors)
1151
+ - `:focus-visible` indicator (can be styled via CSS custom property)
1152
+ - Line numbers are `aria-hidden="true"` (not part of screen reader navigation)
1153
+
1154
+ ### Spellcheck Disabled
1155
+
1156
+ Spell checking is disabled to prevent vendor-specific squiggles from interfering with custom decorations. Set explicitly in the editor:
1157
+
1158
+ ```ts
1159
+ editor.spellcheck = false;
1160
+ editor.autocorrect = 'off';
1161
+ editor.autocapitalize = 'off';
1162
+ ```
1163
+
1164
+ ---
1165
+
1166
+ ## Performance Considerations
1167
+
1168
+ ### DOM Rebuild on Every Render
1169
+
1170
+ The blot tree is **completely rebuilt** on each render frame (RAF-batched). This is necessary because:
1171
+ - Decoration changes require DOM restructuring
1172
+ - Plain-text ↔ DOM offset conversion requires a stable tree
1173
+ - Cursor restoration requires rebuilding after changes
1174
+
1175
+ For documents **< 10,000 characters**, this is imperceptible. For very large documents, consider:
1176
+ - Truncating visible content (virtual scrolling)
1177
+ - Debouncing plugin updates
1178
+ - Using pattern decorations instead of plugin updates (patterns are optimized)
1179
+
1180
+ ### Decoration Density
1181
+
1182
+ Large numbers of overlapping decorations (e.g., 1000+ marks on a single line) will impact performance. Keep decoration counts reasonable:
1183
+ - Syntax highlighting: typically 10–100 marks per line
1184
+ - Error markers: typically 1–5 per line
1185
+ - Patterns: typically < 50 matches per line
1186
+
1187
+ If density is high, profile with DevTools to identify bottlenecks.
1188
+
1189
+ ### Memory
1190
+
1191
+ The undo/redo stack stores 100 entries of `{ value, anchorOffset, focusOffset }`. For a 10 KB document, this is ~1 MB. For very large documents, consider limiting undo depth or using an external undo manager.
1192
+
1193
+ ---
1194
+
1195
+ ## Troubleshooting
1196
+
1197
+ ### Selection is lost after edit
1198
+
1199
+ Normal behavior — selection is saved before render and restored after. If you're updating text and immediately reading `selectionStart`, use `onCursorMove()` or `setTimeout()` to wait for the next render frame.
1200
+
1201
+ ```ts
1202
+ editor.value = 'new text';
1203
+ // DON'T do this:
1204
+ console.log(editor.selectionStart); // May be stale
1205
+
1206
+ // DO this:
1207
+ await editor.updateComplete;
1208
+ const start = editor.value === 'new text' ? api.selectionStart : null;
1209
+ ```
1210
+
1211
+ ### Decorations disappear after large paste
1212
+
1213
+ If you paste a large block of text (> 50 chars and > 50% of document), plugin decorations are cleared by the "large change heuristic" to avoid mapping errors. Pattern decorations are reapplied. To preserve plugin decorations, update them in the plugin's `update()` method:
1214
+
1215
+ ```ts
1216
+ editor.usePlugin({
1217
+ update(value, api) {
1218
+ // Recompute decorations from the new value
1219
+ const decs = parseText(value);
1220
+ api.setDecorations(decs);
1221
+ },
1222
+ });
1223
+ ```
1224
+
1225
+ ### Other's DOM change events fire inside editor
1226
+
1227
+ The component does not suppress mutation events on the editor. If you're listening for `MutationObserver` events on the editor element, you'll observe all blot tree rebuilds. To avoid this, listen outside the editor or throttle updates.
1228
+
1229
+ ### cursor doesn't stay at expected position
1230
+
1231
+ Cursor restoration uses plain-text offsets. If decorations change (especially widgets, which take up no space in the value), the visual cursor position may shift. This is expected. Use `getCursorRect()` to anchor UI elements if precise positioning is critical.
1232
+
1233
+ ### Text is very long (> 100 KB), editor is slow
1234
+
1235
+ The entire blot tree is rebuilt on each character input. For very large documents:
1236
+ - Profile with DevTools Performance tab to identify bottleneck
1237
+ - Consider truncating visible content (virtual scrolling)
1238
+ - Use `word-wrap: false` to reduce line breaks (fewer blots)
1239
+ - Limit undo depth: only keep last 10 entries instead of 100
1240
+
1241
+ ### Plugin's `highlight()` result is overwritten immediately
1242
+
1243
+ If both `update()` and `highlight()` are provided, only one runs per render. The component runs `highlight()` if it returns a truthy value; otherwise, `update()` is called.
1244
+
1245
+ ---
1246
+
1247
+ ## API Reference Summary
1248
+
1249
+ ### Properties
1250
+
1251
+ | Property | Type | Default | Reflects |
1252
+ |----------|------|---------|----------|
1253
+ | `value` | `string` | `''` | N/A |
1254
+ | `lineNumbers` | `boolean` | `false` | Yes (*line-numbers* attr) |
1255
+ | `listNumbers` | `boolean` | `false` | Yes (*list-numbers* attr) |
1256
+ | `gutter` | `boolean` | `false` | Yes (*gutter* attr) |
1257
+ | `wordWrap` | `boolean` | `false` | Yes (*word-wrap* attr) |
1258
+ | `autoGrow` | `boolean` | `false` | Yes (*auto-grow* attr) |
1259
+ | `readOnly` | `boolean` | `false` | Yes (*read-only* attr) |
1260
+ | `label` | `string \| null` | `null` | No |
1261
+ | `selectionStart` | `number` | `0` | No (readonly) |
1262
+ | `selectionEnd` | `number` | `0` | No (readonly) |
1263
+
1264
+ ### Methods
1265
+
1266
+ | Method | Signature | Returns |
1267
+ |--------|-----------|---------|
1268
+ | `usePlugin()` | `(plugin: RCTextareaPlugin) => void` | — |
1269
+ | `removePlugin()` | `() => void` | — |
1270
+ | `addPattern()` | `(pattern: Omit<TextPattern, 'id'>) => string` | Pattern ID |
1271
+ | `removePattern()` | `(id: string) => void` | — |
1272
+ | `clearPatterns()` | `() => void` | — |
1273
+
1274
+ ### Exported Helpers
1275
+
1276
+ | Export | Kind | Description |
1277
+ | ------ | ---- | ----------- |
1278
+ | `matchPatternResults` | function | Run `TextPattern[]` against a string; returns `{ markDecorations, lineDecorations }` |
1279
+ | `createLineDecoratorPlugin` | function | Wrap a `LineDecoratorPlugin` in a full `RCTextareaPlugin` |
1280
+ | `MarkDecorationStyle` | type | Styling-only subset of `MarkDecoration` (used in `TextPattern.captureGroups`) |
1281
+ | `LineDecoratorPlugin` | interface | Per-line decorator with line-relative offsets |
1282
+ | `LineDecoratorPluginOptions` | interface | Options for `createLineDecoratorPlugin` |
1283
+
1284
+ ### Events
1285
+
1286
+ | Event | Bubbles | Composed | Detail |
1287
+ |-------|---------|----------|--------|
1288
+ | `rc-textarea-change` | Yes | Yes | `{ value: string }` |
1289
+ | `rc-textarea-focus` | Yes | Yes | — |
1290
+ | `rc-textarea-blur` | Yes | Yes | — |
1291
+ | `rc-textarea-select` | Yes | Yes | `{ selectionStart: number, selectionEnd: number }` |
1292
+
1293
+ ### CSS Custom Properties
1294
+
1295
+ All custom properties are listed in [CSS Customization](#css-customization).
1296
+
1297
+ ### CSS Parts
1298
+
1299
+ All parts are listed in [CSS Customization](#css-customization).
1300
+
1301
+ ---
1302
+
1303
+ ## Support & Contributing
1304
+
1305
+ For issues, feature requests, or contributions, visit the [rc-webcomponents repository](https://github.com/richardcarls/rc-webcomponents).
1306
+
1307
+ ---
1308
+
1309
+ ## License
1310
+
1311
+ [MIT](LICENSE) © Richard Carls