@rcarls/rc-textarea 0.1.0 → 0.3.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.
package/README.md CHANGED
@@ -1,128 +1,24 @@
1
1
  # rc-textarea
2
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>`.
3
+ Textarea wrapper with line decorations, gutter rendering, inline widgets, and plugin hooks.
4
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.
5
+ Docs: [https://richardcarls.github.io/rc-webcomponents/components/rc-textarea](https://richardcarls.github.io/rc-webcomponents/components/rc-textarea).
6
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)
7
+ Made with [Lit](https://lit.dev) and [Parchment](https://github.com/quilljs/parchment).
31
8
 
32
9
  ---
33
10
 
34
- ## Key Capabilities
11
+ ## Feature Highlights
35
12
 
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) |
13
+ - **Mixed inline formatting** - bold, italic, color, background, and underline
14
+ - **Imperative APIs** that behave well with reactive frameworks
15
+ - **Error-lens style line annotations** that stay separate from text content
16
+ - **Inline widgets** - color swatches, icons, tooltips or quick action buttons are possible
17
+ - **Simple Pattern API** that auto-decorates text matching regular expressions
18
+ - **Plugin API** - imperative decoration control + highlight.js / prism.js HTML compatibility bridge
19
+ - **Line numbers, word wrap, auto-grow** - declarative features for common use cases
20
+ - **Progressive enhancement** - wraps a native `<textarea>` for form submission, label association
21
+ - **Undo/redo** - durable internally-tracked undo stack
126
22
 
127
23
  ---
128
24
 
@@ -130,411 +26,185 @@ Plain-text offsets are essential for:
130
26
 
131
27
  ```bash
132
28
  npm install @rcarls/rc-textarea
133
- # or
29
+ ```
30
+
31
+ ```bash
134
32
  yarn add @rcarls/rc-textarea
135
33
  ```
136
34
 
137
- Import to auto-register the custom element:
35
+ Import the define entry to register `<rc-textarea>`:
138
36
 
139
37
  ```ts
140
- import '@rcarls/rc-textarea';
38
+ import '@rcarls/rc-textarea/define';
141
39
  ```
142
40
 
143
- Or import the class for typed access:
41
+ Import public types when you need typed access:
144
42
 
145
43
  ```ts
146
- import { RCTextarea } from '@rcarls/rc-textarea';
44
+ import type { RCTextarea, RCTextareaPlugin } from '@rcarls/rc-textarea';
147
45
  ```
148
46
 
149
- ---
150
-
151
47
  ## Basic Usage
152
48
 
153
- Slot a native `<textarea>` as the direct child. It is hidden from view and used only for form wiring.
49
+ Slot a native `<textarea>` as the direct child. It is hidden from view and used
50
+ only for form wiring.
154
51
 
155
52
  ```html
156
- <rc-textarea>
157
- <textarea name="body" rows="10" placeholder="Start typing…"></textarea>
53
+ <label for="message">Message</label>
54
+
55
+ <rc-textarea line-numbers word-wrap auto-grow>
56
+ <textarea id="message" name="message" rows="10" placeholder="Start typing..."></textarea>
158
57
  </rc-textarea>
159
58
  ```
160
59
 
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
60
+ Use the `value` property for controlled updates, `defaultValue` for an initial uncontrolled
61
+ value, and `rc-textarea-change` for user-originated changes.
164
62
 
165
63
  ```ts
166
64
  const editor = document.querySelector('rc-textarea');
167
65
 
168
- // Read current plain text
169
- console.log(editor.value);
170
-
171
- // Set programmatically
172
- editor.value = 'new content';
66
+ editor.value = 'Programmatic updates are silent.';
173
67
 
174
- // Track changes
175
- editor.addEventListener('rc-textarea-change', (e) => {
176
- console.log('New value:', e.detail.value);
68
+ editor.addEventListener('rc-textarea-change', (event) => {
69
+ console.log(event.detail.value);
177
70
  });
178
71
  ```
179
72
 
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
73
+ When the user edits, the slotted textarea is kept in sync and dispatches a native bubbling
74
+ `input` event. Submitting a form reads the textarea's plain-text value normally.
197
75
 
198
76
  ```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
- ```
77
+ <form>
78
+ <rc-textarea>
79
+ <textarea name="body" required maxlength="5000"></textarea>
80
+ </rc-textarea>
209
81
 
210
- ```ts
211
- const editor = document.querySelector('#code-editor');
212
- console.log(editor.lineNumbers); // true
213
- console.log(editor.wordWrap); // true
82
+ <button type="submit">Send</button>
83
+ </form>
214
84
  ```
215
85
 
216
- ---
86
+ ## Common Options
217
87
 
218
- ## Value Property
88
+ | Attribute | Property | Description |
89
+ | -------------- | ------------- | ----------------------------------------------------------------------------------- |
90
+ | `line-numbers` | `lineNumbers` | Show sequential line numbers in the gutter. |
91
+ | `gutter` | `gutter` | Show an empty gutter that plugins can populate with `LineDecoration.gutterContent`. |
92
+ | `word-wrap` | `wordWrap` | Wrap long lines instead of scrolling horizontally. |
93
+ | `auto-grow` | `autoGrow` | Let the field grow vertically with content. |
94
+ | `read-only` | `readOnly` | Render selectable, non-editable content. |
219
95
 
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';
96
+ `list-numbers` and `label` still exist for compatibility but are deprecated. Prefer a plugin
97
+ with `LineDecoration.gutterContent` for sparse numbering, and put accessible names on the
98
+ slotted textarea with `aria-label` or a real `<label for="...">`.
228
99
 
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)
100
+ The main JavaScript-only properties are:
240
101
 
241
- ---
102
+ | Property | Type | Description |
103
+ | --------------------------------- | -------------------------- | ------------------------------------------------------------------------- |
104
+ | `value` | `string` | Current plain-text value. Host writes are silent. |
105
+ | `defaultValue` | `string \| undefined` | Initial uncontrolled value, used before `value` or textarea content wins. |
106
+ | `plugin` | `RCTextareaPlugin \| null` | Declarative plugin hook for reactive frameworks. |
107
+ | `decorations` | `DecorationInput[]` | External decoration layer merged with plugin and pattern decorations. |
108
+ | `selectionStart` / `selectionEnd` | `number` | Current plain-text selection offsets. |
242
109
 
243
110
  ## Events
244
111
 
245
- All events bubble and are composed (cross shadow boundary).
112
+ All public events bubble and are composed.
246
113
 
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) |
114
+ | Event | Detail | Fires when |
115
+ | -------------------- | -------------------------------------------------- | -------------------------------------------------- |
116
+ | `rc-textarea-change` | `{ value: string }` | User editing changes the plain-text value. |
117
+ | `rc-textarea-focus` | none | The editor receives focus. |
118
+ | `rc-textarea-blur` | none | The editor loses focus. |
119
+ | `rc-textarea-select` | `{ selectionStart: number, selectionEnd: number }` | The selection changes while the editor is focused. |
253
120
 
254
- ### Example
121
+ ## Pattern Highlights
255
122
 
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
- });
123
+ Use `addPattern()` for lightweight regex decoration without writing a plugin.
267
124
 
268
- editor.addEventListener('rc-textarea-focus', () => {
269
- console.log('Editor focused');
125
+ ```ts
126
+ const todoPatternId = editor.addPattern({
127
+ pattern: /\bTODO\b/g,
128
+ bold: true,
129
+ color: 'var(--editor-todo-color)',
270
130
  });
271
- ```
272
-
273
- ---
274
131
 
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
- }
132
+ editor.removePattern(todoPatternId);
133
+ editor.clearPatterns();
338
134
  ```
339
135
 
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:
136
+ Patterns can also style named capture groups and add line diagnostics.
343
137
 
344
138
  ```ts
345
- let api: RCTextareaPluginAPI;
346
- editor.usePlugin({
347
- mount(a) { api = a; },
348
- update() {},
139
+ editor.addPattern({
140
+ pattern: /^(?<key>\w[\w-]*):\s*(?<value>.+)$/gm,
141
+ captureGroups: {
142
+ key: { bold: true, color: 'var(--editor-key-color)' },
143
+ value: { color: 'var(--editor-value-color)' },
144
+ },
145
+ createLineDecoration: () => ({ className: 'config-line' }),
349
146
  });
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
147
  ```
361
148
 
362
- ---
363
-
364
- ## Plugin API
149
+ See [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) for the complete decoration and plugin model.
365
150
 
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.
151
+ ## Markdown Plugin Package
367
152
 
368
- ### Interface Overview
153
+ Install the Markdown plugin package when you want Markdown-oriented decorations and preview
154
+ HTML without writing your own parser bridge.
369
155
 
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
- }
156
+ ```bash
157
+ npm install @rcarls/rc-textarea @rcarls/rc-textarea-plugin-markdown \
158
+ mdast-util-from-markdown micromark unist-util-visit
392
159
  ```
393
160
 
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
- }
161
+ ```bash
162
+ yarn add @rcarls/rc-textarea @rcarls/rc-textarea-plugin-markdown \
163
+ mdast-util-from-markdown micromark unist-util-visit
422
164
  ```
423
165
 
424
- ### Mounting and Lifecycle
425
-
426
166
  ```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
- ```
167
+ import '@rcarls/rc-textarea/define';
168
+ import { createMarkdownPlugin } from '@rcarls/rc-textarea-plugin-markdown';
452
169
 
453
- ### Example 1: Synchronous Imperative Plugin
170
+ const editor = document.querySelector('rc-textarea');
171
+ const markdown = createMarkdownPlugin();
454
172
 
455
- Manually find patterns and apply decorations:
173
+ editor.usePlugin(markdown);
456
174
 
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);
175
+ preview.innerHTML = markdown.getPreviewHtml(editor.value);
490
176
  ```
491
177
 
492
- ### Example 2: Asynchronous Plugin (WASM / Web Worker)
178
+ The package decorates common Markdown syntax using `mdast-util-from-markdown` and exposes
179
+ `getMarkdownPreviewHtml()` / `plugin.getPreviewHtml()` for preview rendering.
493
180
 
494
- ```ts
495
- editor.usePlugin({
496
- async update(value, api) {
497
- const decorations = await myWasmParser.tokenize(value);
498
- api.setDecorations(decorations);
499
- },
500
- });
501
- ```
181
+ ## highlight.js And Prism Bridge
502
182
 
503
- Stale results are automatically discarded if a newer render starts before the promise resolves.
504
-
505
- ### Example 3: highlight.js Integration
183
+ `rc-textarea` can consume the HTML strings produced by highlighters that wrap token text in
184
+ `<span class="...">...</span>` nodes. Return that HTML from `highlight()` or call
185
+ `api.parseDecorationsFromHtml()` yourself inside `update()`.
506
186
 
507
187
  ```ts
508
188
  import hljs from 'highlight.js/lib/core';
509
189
  import javascript from 'highlight.js/lib/languages/javascript';
190
+
510
191
  hljs.registerLanguage('javascript', javascript);
511
192
 
512
193
  editor.usePlugin({
513
194
  mount(api) {
514
- // Inject theme CSS into shadow root (hljs classes live inside)
515
195
  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; }
196
+ .hljs-keyword { color: var(--editor-syntax-keyword); font-weight: 600; }
197
+ .hljs-string { color: var(--editor-syntax-string); }
198
+ .hljs-comment { color: var(--editor-syntax-comment); font-style: italic; }
523
199
  `);
524
200
  },
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));
201
+
202
+ highlight(value) {
203
+ return hljs.highlight(value, { language: 'javascript' }).value;
530
204
  },
531
205
  });
532
206
  ```
533
207
 
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
208
  ```ts
539
209
  import Prism from 'prismjs';
540
210
  import 'prismjs/components/prism-python';
@@ -542,770 +212,97 @@ import 'prismjs/components/prism-python';
542
212
  editor.usePlugin({
543
213
  mount(api) {
544
214
  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; }
215
+ .token.keyword { color: var(--editor-syntax-keyword); font-weight: 600; }
216
+ .token.string { color: var(--editor-syntax-string); }
217
+ .token.comment { color: var(--editor-syntax-comment); font-style: italic; }
550
218
  `);
551
219
  },
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
220
 
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
- }
221
+ highlight(value) {
222
+ return Prism.highlight(value, Prism.languages.python, 'python');
580
223
  },
581
224
  });
582
225
  ```
583
226
 
584
- ---
227
+ For Lezer, Unified, and Shiki integrations, see `@rcarls/rc-textarea-adapters`.
585
228
 
586
- ## Decoration Types
229
+ ## Theming
587
230
 
588
- Decorations are objects describing visual changes. All decorations are immutable after creation (API methods return new IDs or replace entire sets).
231
+ `rc-textarea` is design-system neutral and uses CSS system colors by default. Theme it with broad tokens
232
+ for the field, then add component tokens for editor-specific surfaces.
589
233
 
590
- ### MarkDecoration styled character range
234
+ ### Broad Theme Tokens
591
235
 
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.
236
+ The component reads inherited tokens where possible:
593
237
 
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
- ```
238
+ | Token | Use |
239
+ | -------------- | ------------------------------------------------------------ |
240
+ | `--rc-text` | Fallback text color before `--rc-textarea-color`. |
241
+ | `color-scheme` | Inherited by the host so system colors match the page theme. |
610
242
 
611
- ### LineDecoration — whole-line styling + error-lens annotation
243
+ ### Component Tokens
612
244
 
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.
245
+ | Token | Default | Use |
246
+ | ----------------------------------------- | ------------------------------------- | ------------------------------------- |
247
+ | `--rc-textarea-font-family` | `monospace` | Editor and gutter font family. |
248
+ | `--rc-textarea-font-size` | `1em` | Editor and gutter font size. |
249
+ | `--rc-textarea-line-height` | `1.5` | Editor and gutter line height. |
250
+ | `--rc-textarea-padding` | `0.5em` | Editor and gutter padding. |
251
+ | `--rc-textarea-background` | `Field` | Field background. |
252
+ | `--rc-textarea-color` | `var(--rc-text, FieldText)` | Field text color. |
253
+ | `--rc-textarea-caret-color` | `var(--rc-textarea-color, FieldText)` | Caret color. |
254
+ | `--rc-textarea-border` | `1px solid ButtonBorder` | Field border. |
255
+ | `--rc-textarea-border-radius` | `2px` | Field corner radius. |
256
+ | `--rc-textarea-focus-outline` | `2px solid Highlight` | Focus ring. |
257
+ | `--rc-textarea-active-line-bg` | `transparent` | Active line background. |
258
+ | `--rc-textarea-gutter-bg` | `Canvas` | Gutter background. |
259
+ | `--rc-textarea-gutter-color` | `GrayText` | Gutter text color. |
260
+ | `--rc-textarea-gutter-border` | `1px solid ButtonBorder` | Gutter separator. |
261
+ | `--rc-textarea-gutter-padding-inline-end` | `0.75em` | Space between gutter labels and text. |
632
262
 
633
263
  ```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;
264
+ rc-textarea {
265
+ color-scheme: dark;
266
+ --rc-textarea-font-family: 'Fira Code', monospace;
267
+ --rc-textarea-font-size: 13px;
268
+ --rc-textarea-background: #1e1e2e;
269
+ --rc-textarea-color: #cdd6f4;
270
+ --rc-textarea-border: 1px solid #313244;
271
+ --rc-textarea-active-line-bg: rgb(255 255 255 / 0.06);
737
272
  }
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
273
  ```
745
274
 
746
- ### Example: Simple Pattern
275
+ ### Parts And Decoration Styles
747
276
 
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
- ```
277
+ The exposed CSS parts are `root`, `gutter`, `gutter-cells`, `editor-area`, and `editor`.
757
278
 
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;
279
+ ```css
280
+ rc-textarea::part(editor) {
281
+ tab-size: 2;
968
282
  }
969
283
  ```
970
284
 
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
- ---
285
+ Decoration elements are inside the shadow root. Use `api.adoptStyleSheet()` from a plugin to
286
+ style custom classes such as `.my-highlight`, `.line[data-message]`, or token classes from a
287
+ syntax highlighter.
1078
288
 
1079
289
  ## Keyboard Behavior
1080
290
 
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)
291
+ | Key | Behavior |
292
+ | --------------------------------- | ------------------------------------------------------------ |
293
+ | `Tab` | Inserts `\t`. |
294
+ | `Ctrl/Cmd+Z` | Undo. |
295
+ | `Ctrl/Cmd+Y` / `Ctrl/Cmd+Shift+Z` | Redo. |
296
+ | `Paste` | Inserts plain text only and normalizes line endings to `\n`. |
297
+ | `Enter` | Inserts a line break. |
1179
298
 
1180
- ### Decoration Density
299
+ ## More Detail
1181
300
 
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
- ---
301
+ - [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) covers custom plugins, decoration types,
302
+ selection APIs, stylesheet injection, and parser/highlighter recipes.
303
+ - [ARCHITECTURE.md](ARCHITECTURE.md) is an internal contributor reference for the rendering
304
+ loop, Parchment integration, selection mapping, and gutter synchronization.
1308
305
 
1309
306
  ## License
1310
307
 
1311
- [MIT](LICENSE) © Richard Carls
308
+ [MIT](../../LICENSE)