@citisen/litearea 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.
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/README.zh.md +362 -0
- package/dist/grammars.cjs +1228 -0
- package/dist/grammars.cjs.map +1 -0
- package/dist/grammars.js +1213 -0
- package/dist/grammars.js.map +1 -0
- package/dist/index.cjs +3103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +3040 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +3032 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +3010 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.cjs +453 -0
- package/dist/styles.cjs.map +1 -0
- package/dist/styles.css +432 -0
- package/dist/styles.js +447 -0
- package/dist/styles.js.map +1 -0
- package/dist/types/core/complete.d.ts +70 -0
- package/dist/types/core/complete.d.ts.map +1 -0
- package/dist/types/core/format.d.ts +34 -0
- package/dist/types/core/format.d.ts.map +1 -0
- package/dist/types/core/grammar.d.ts +14 -0
- package/dist/types/core/grammar.d.ts.map +1 -0
- package/dist/types/core/hover.d.ts +23 -0
- package/dist/types/core/hover.d.ts.map +1 -0
- package/dist/types/core/index.d.ts +12 -0
- package/dist/types/core/index.d.ts.map +1 -0
- package/dist/types/core/inspect.d.ts +34 -0
- package/dist/types/core/inspect.d.ts.map +1 -0
- package/dist/types/core/rank.d.ts +82 -0
- package/dist/types/core/rank.d.ts.map +1 -0
- package/dist/types/core/scan.d.ts +51 -0
- package/dist/types/core/scan.d.ts.map +1 -0
- package/dist/types/core/segments.d.ts +44 -0
- package/dist/types/core/segments.d.ts.map +1 -0
- package/dist/types/core/text.d.ts +113 -0
- package/dist/types/core/text.d.ts.map +1 -0
- package/dist/types/core/types.d.ts +604 -0
- package/dist/types/core/types.d.ts.map +1 -0
- package/dist/types/core/vocabulary.d.ts +82 -0
- package/dist/types/core/vocabulary.d.ts.map +1 -0
- package/dist/types/dom/create.d.ts +17 -0
- package/dist/types/dom/create.d.ts.map +1 -0
- package/dist/types/dom/editing.d.ts +89 -0
- package/dist/types/dom/editing.d.ts.map +1 -0
- package/dist/types/dom/editor.d.ts +366 -0
- package/dist/types/dom/editor.d.ts.map +1 -0
- package/dist/types/dom/index.d.ts +9 -0
- package/dist/types/dom/index.d.ts.map +1 -0
- package/dist/types/dom/mirror.d.ts +107 -0
- package/dist/types/dom/mirror.d.ts.map +1 -0
- package/dist/types/dom/overlay.d.ts +52 -0
- package/dist/types/dom/overlay.d.ts.map +1 -0
- package/dist/types/dom/popup.d.ts +95 -0
- package/dist/types/dom/popup.d.ts.map +1 -0
- package/dist/types/dom/support.d.ts +41 -0
- package/dist/types/dom/support.d.ts.map +1 -0
- package/dist/types/dom/tooltip.d.ts +39 -0
- package/dist/types/dom/tooltip.d.ts.map +1 -0
- package/dist/types/grammars/dshFont.d.ts +127 -0
- package/dist/types/grammars/dshFont.d.ts.map +1 -0
- package/dist/types/grammars/dshSentry.d.ts +84 -0
- package/dist/types/grammars/dshSentry.d.ts.map +1 -0
- package/dist/types/grammars/index.d.ts +3 -0
- package/dist/types/grammars/index.d.ts.map +1 -0
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/react/index.d.ts +91 -0
- package/dist/types/react/index.d.ts.map +1 -0
- package/dist/types/styles.d.ts +29 -0
- package/dist/types/styles.d.ts.map +1 -0
- package/docs/architecture.md +316 -0
- package/docs/completion.md +320 -0
- package/docs/grammar.md +823 -0
- package/package.json +105 -0
- package/scripts/browser-check.mjs +838 -0
- package/scripts/build-css.mjs +35 -0
- package/scripts/release.mjs +91 -0
- package/scripts/verify-package.mjs +253 -0
- package/src/core/complete.ts +286 -0
- package/src/core/format.ts +71 -0
- package/src/core/grammar.ts +40 -0
- package/src/core/hover.ts +129 -0
- package/src/core/index.ts +98 -0
- package/src/core/inspect.ts +198 -0
- package/src/core/rank.ts +317 -0
- package/src/core/scan.ts +720 -0
- package/src/core/segments.ts +185 -0
- package/src/core/text.ts +238 -0
- package/src/core/types.ts +681 -0
- package/src/core/vocabulary.ts +196 -0
- package/src/dom/create.ts +31 -0
- package/src/dom/editing.ts +213 -0
- package/src/dom/editor.ts +1143 -0
- package/src/dom/index.ts +46 -0
- package/src/dom/mirror.ts +305 -0
- package/src/dom/overlay.ts +106 -0
- package/src/dom/popup.ts +323 -0
- package/src/dom/support.ts +88 -0
- package/src/dom/tooltip.ts +112 -0
- package/src/grammars/dshFont.ts +1004 -0
- package/src/grammars/dshSentry.ts +742 -0
- package/src/grammars/index.ts +57 -0
- package/src/index.ts +122 -0
- package/src/react/index.tsx +248 -0
- package/src/styles.ts +529 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { Completion, Diagnostic, Grammar, HoverInfo } from '../core/types.js';
|
|
3
|
+
import type { ResolvedGrammar } from '../core/scan.js';
|
|
4
|
+
import { LiteArea, type LiteAreaCompletion, type LiteAreaHover, type LiteAreaSizing } from '../dom/editor.js';
|
|
5
|
+
import type { TextSelection } from '../dom/editing.js';
|
|
6
|
+
/** Everything the React component accepts. */
|
|
7
|
+
export interface LiteAreaEditorProps<State = unknown> {
|
|
8
|
+
/**
|
|
9
|
+
* The language.
|
|
10
|
+
*
|
|
11
|
+
* It does NOT have to be stable: the component hands the engine a live view of
|
|
12
|
+
* whatever was passed last, so rebuilding the object on every render is safe and
|
|
13
|
+
* the editor is not rebuilt.
|
|
14
|
+
*/
|
|
15
|
+
grammar: Grammar<State> | ResolvedGrammar<State>;
|
|
16
|
+
/**
|
|
17
|
+
* The text to start with. Read ONCE, when the editor mounts.
|
|
18
|
+
*
|
|
19
|
+
* This is the prop to use. A changing initial value is what `value` is for, and it
|
|
20
|
+
* cannot be done by re-rendering, because that is the thing that breaks undo.
|
|
21
|
+
*/
|
|
22
|
+
defaultValue?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Text the host wants shown.
|
|
25
|
+
*
|
|
26
|
+
* When this differs from the field it is written — through the browser's editing
|
|
27
|
+
* pipeline, so it stays undoable. Leaving it undefined is the normal case: the
|
|
28
|
+
* field owns the text.
|
|
29
|
+
*/
|
|
30
|
+
value?: string;
|
|
31
|
+
/** Whether a `value` write should be one undoable edit. Default `false`. */
|
|
32
|
+
preserveHistory?: boolean;
|
|
33
|
+
/** Whether the user may edit. */
|
|
34
|
+
readOnly?: boolean;
|
|
35
|
+
/** Shown while the box is empty. */
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/** Whether the browser may spell-check. Default `false`. */
|
|
38
|
+
spellCheck?: boolean;
|
|
39
|
+
/** The accessible name. */
|
|
40
|
+
ariaLabel?: string;
|
|
41
|
+
/** A class on the wrapper element. */
|
|
42
|
+
className?: string;
|
|
43
|
+
/** Styles for the wrapper element. */
|
|
44
|
+
style?: React.CSSProperties;
|
|
45
|
+
/**
|
|
46
|
+
* CSS custom properties for this instance — a theme, a font, or both.
|
|
47
|
+
*
|
|
48
|
+
* Unlike the options read at mount, this one is applied live, and an inline object literal is
|
|
49
|
+
* fine: the records are compared by value, so a fresh `{ font: '…' }` on every render does not
|
|
50
|
+
* mean work on every render.
|
|
51
|
+
*/
|
|
52
|
+
variables?: Record<string, string>;
|
|
53
|
+
/** How the box follows its content. Read when the editor mounts. */
|
|
54
|
+
sizing?: LiteAreaSizing;
|
|
55
|
+
/** How the list behaves, or `false` to switch completions off. Read when the editor mounts. */
|
|
56
|
+
completion?: LiteAreaCompletion | false;
|
|
57
|
+
/** How tooltips behave, or `false` to switch them off. Read when the editor mounts. */
|
|
58
|
+
hover?: LiteAreaHover | false;
|
|
59
|
+
/** Whether semantic decorations are painted. Read when the editor mounts. */
|
|
60
|
+
decorations?: boolean;
|
|
61
|
+
/** Whether to inject the stylesheet. Read when the editor mounts. */
|
|
62
|
+
injectStyles?: boolean;
|
|
63
|
+
/** A CSP nonce for the injected stylesheet. */
|
|
64
|
+
styleNonce?: string;
|
|
65
|
+
/** Called after a user edit, with the new text. */
|
|
66
|
+
onChange?: (value: string) => void;
|
|
67
|
+
/** Called when the caret or selection moves. */
|
|
68
|
+
onSelectionChange?: (selection: TextSelection) => void;
|
|
69
|
+
/** Called when the problem list changes. */
|
|
70
|
+
onDiagnostics?: (diagnostics: readonly Diagnostic[]) => void;
|
|
71
|
+
/** Called when the list opens, filters, or closes. */
|
|
72
|
+
onCompletion?: (completion: Completion | undefined) => void;
|
|
73
|
+
/** Called when a tooltip appears or goes away. */
|
|
74
|
+
onHover?: (info: HoverInfo | undefined) => void;
|
|
75
|
+
/**
|
|
76
|
+
* Handed the editor once it exists, and `undefined` when it goes away.
|
|
77
|
+
*
|
|
78
|
+
* The escape hatch for anything the props do not cover: undo and redo, focusing
|
|
79
|
+
* the field, or reading the diagnostics directly.
|
|
80
|
+
*/
|
|
81
|
+
editorRef?: (editor: LiteArea<State> | undefined) => void;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A code editor, as a React component.
|
|
85
|
+
*
|
|
86
|
+
* See the module comment for why this does not take the text through React state.
|
|
87
|
+
* @param props - the grammar and the behaviour.
|
|
88
|
+
* @returns the wrapper element the editor mounts into.
|
|
89
|
+
*/
|
|
90
|
+
export declare function LiteAreaEditor<State = unknown>(props: LiteAreaEditorProps<State>): React.ReactElement;
|
|
91
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/index.tsx"],"names":[],"mappings":"AAoBA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAClF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AACtD,OAAO,EAAE,QAAQ,EAAE,KAAK,kBAAkB,EAAE,KAAK,aAAa,EAAE,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAC7G,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEtD,8CAA8C;AAC9C,MAAM,WAAW,mBAAmB,CAAC,KAAK,GAAG,OAAO;IAClD;;;;;;OAMG;IACH,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG,eAAe,CAAC,KAAK,CAAC,CAAA;IAChD;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,4EAA4E;IAC5E,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,4DAA4D;IAC5D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,2BAA2B;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sCAAsC;IACtC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sCAAsC;IACtC,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAA;IAC3B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAClC,oEAAoE;IACpE,MAAM,CAAC,EAAE,cAAc,CAAA;IACvB,+FAA+F;IAC/F,UAAU,CAAC,EAAE,kBAAkB,GAAG,KAAK,CAAA;IACvC,uFAAuF;IACvF,KAAK,CAAC,EAAE,aAAa,GAAG,KAAK,CAAA;IAC7B,6EAA6E;IAC7E,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,qEAAqE;IACrE,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,gDAAgD;IAChD,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,aAAa,KAAK,IAAI,CAAA;IACtD,4CAA4C;IAC5C,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,SAAS,UAAU,EAAE,KAAK,IAAI,CAAA;IAC5D,sDAAsD;IACtD,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,GAAG,SAAS,KAAK,IAAI,CAAA;IAC3D,kDAAkD;IAClD,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,GAAG,SAAS,KAAK,IAAI,CAAA;IAC/C;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,GAAG,SAAS,KAAK,IAAI,CAAA;CAC1D;AAqDD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,KAAK,GAAG,OAAO,EAC5C,KAAK,EAAE,mBAAmB,CAAC,KAAK,CAAC,GAChC,KAAK,CAAC,YAAY,CAmFpB"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Everything a scope or a mark is painted with comes from one of these. */
|
|
2
|
+
export declare const LITEAREA_STYLES: string;
|
|
3
|
+
/**
|
|
4
|
+
* Put the stylesheet in a document, once.
|
|
5
|
+
*
|
|
6
|
+
* Marked with a data attribute so two editors on one page, or two copies of the
|
|
7
|
+
* library, do not stack two identical sheets — and so a host can find and replace
|
|
8
|
+
* it.
|
|
9
|
+
* @param ownerDocument - the document to install into.
|
|
10
|
+
* @param nonce - an optional CSP nonce for the style element.
|
|
11
|
+
* @returns the style element, or undefined when there is no document.
|
|
12
|
+
*/
|
|
13
|
+
export declare function injectStyles(ownerDocument: Document, nonce?: string): HTMLStyleElement | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* The class a scope is painted with.
|
|
16
|
+
*
|
|
17
|
+
* Dots and other punctuation are folded to hyphens, because a scope like
|
|
18
|
+
* `value.shape` is a nice name to write and a terrible one to select: the CSS
|
|
19
|
+
* would need a backslash before the dot at every use, and one forgotten escape
|
|
20
|
+
* silently paints nothing.
|
|
21
|
+
* @param scope - the scope name.
|
|
22
|
+
* @returns the class name.
|
|
23
|
+
*/
|
|
24
|
+
export declare function scopeClass(scope: string): string;
|
|
25
|
+
/** The class a decoration kind is painted with. */
|
|
26
|
+
export declare function decorationClass(kind: string): string;
|
|
27
|
+
/** The class a diagnostic severity is underlined with. */
|
|
28
|
+
export declare function severityClass(severity: string): string;
|
|
29
|
+
//# sourceMappingURL=styles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"styles.d.ts","sourceRoot":"","sources":["../../src/styles.ts"],"names":[],"mappings":"AA4FA,4EAA4E;AAC5E,eAAO,MAAM,eAAe,QA+XpB,CAAA;AAER;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,aAAa,EAAE,QAAQ,EACvB,KAAK,CAAC,EAAE,MAAM,GACb,gBAAgB,GAAG,SAAS,CAY9B;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGhD;AAED,mDAAmD;AACnD,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,0DAA0D;AAC1D,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEtD"}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Why litearea is shaped the way it is. The README says what it does; this says what
|
|
4
|
+
each decision costs and what it was chosen over, because most of them are only
|
|
5
|
+
obvious once you have watched the alternative fail.
|
|
6
|
+
|
|
7
|
+
The tree the editor builds:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
div.litearea ← the positioning container, and editor.element
|
|
11
|
+
div.litearea-box ← the border, the radius, and the focus ring
|
|
12
|
+
div.litearea-layer ← the painted text, behind and inert
|
|
13
|
+
textarea ← the real field: transparent text, visible caret
|
|
14
|
+
div.litearea-popup ← the completion list
|
|
15
|
+
div.litearea-tooltip ← the hover tooltip
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The layer is inside the box while the two floating elements are outside it,
|
|
19
|
+
because the box clips nothing and a list that had to fit inside a rounded border
|
|
20
|
+
would be cut off at the bottom.
|
|
21
|
+
|
|
22
|
+
## The one-parse rule
|
|
23
|
+
|
|
24
|
+
`inspect(text, grammar)` produces the tokens, the diagnostics, the decorations, and
|
|
25
|
+
the analysis together, as one value. Everything downstream reads that value and
|
|
26
|
+
nothing re-derives it:
|
|
27
|
+
|
|
28
|
+
| Reader | What it takes from the inspection |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| The layer | `tokens`, `decorations`, `diagnostics` and the text |
|
|
31
|
+
| The completion list | `tokens`, `diagnostics`, `state`, and the document text |
|
|
32
|
+
| The tooltip | `tokens`, `diagnostics`, `decorations`, `state` |
|
|
33
|
+
| `onDiagnostics` | `diagnostics` |
|
|
34
|
+
| `onAccept` | `state` |
|
|
35
|
+
|
|
36
|
+
The editor caches it on the text: `sync()` re-inspects only when
|
|
37
|
+
`input.value` differs from the text the last inspection was computed from, so
|
|
38
|
+
moving the caret, scrolling, or hovering costs nothing beyond the paint. The paint
|
|
39
|
+
itself carries a key — the revision, whether decorations are painted, and the
|
|
40
|
+
decoration and diagnostic counts — and `Overlay.render` returns immediately when
|
|
41
|
+
both the text and the key match the last call. That is what keeps a caret move
|
|
42
|
+
from rebuilding every span on the page.
|
|
43
|
+
|
|
44
|
+
The failure this prevents is not abstract. The editors this library replaces
|
|
45
|
+
re-derived the tokens, the parse, and the suggestions separately, from the same
|
|
46
|
+
string, in three places, and the three drifted: a word could be painted as a valid
|
|
47
|
+
value while the completer thought it was unknown, and a diagnostic pointed at a
|
|
48
|
+
range that had already moved. Those were not three bugs to fix one at a time; they
|
|
49
|
+
were one missing value. The completion list and the coloured text cannot disagree
|
|
50
|
+
here, because there is only one answer to disagree with.
|
|
51
|
+
|
|
52
|
+
The same idea reaches into the DOM layer: `buildSegments` takes the three range
|
|
53
|
+
lists and produces one stream, `complete` takes an inspection and produces rows,
|
|
54
|
+
and `resolveHover` takes an inspection and produces a tooltip. None of them read
|
|
55
|
+
the document again.
|
|
56
|
+
|
|
57
|
+
## Why the textarea is uncontrolled
|
|
58
|
+
|
|
59
|
+
A textarea owns its own undo stack, and that stack is maintained by the browser's
|
|
60
|
+
editing pipeline — not by the `value` property. Assigning `field.value` replaces
|
|
61
|
+
the text and destroys the history, and it resets the caret to the end. So the
|
|
62
|
+
editor never writes `value` for an edit the user could have made:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
field.setSelectionRange(from, to)
|
|
66
|
+
document.execCommand('insertText', false, text) // ← undoable
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Three details in that path are all load-bearing:
|
|
70
|
+
|
|
71
|
+
- **The field is focused first**, because the pipeline only runs on the focused
|
|
72
|
+
element, and a completion accepted by mouse click arrives after the click has
|
|
73
|
+
blurred the field.
|
|
74
|
+
- **The return value is not trusted.** It differs across browsers and does not
|
|
75
|
+
need to be trusted: the *value* decides. A change the editor did not make
|
|
76
|
+
itself is a change the browser's history already knows about, which is the whole
|
|
77
|
+
point, so `field.value !== before` is what tells the two paths apart.
|
|
78
|
+
- **Only the changed range is written.** A completion writes `insert` over
|
|
79
|
+
`[from, to)` and then moves the selection with `setSelectionRange`, which
|
|
80
|
+
touches no text and cannot disturb the history.
|
|
81
|
+
|
|
82
|
+
`execCommand` is deprecated and there is no replacement, and that is not an
|
|
83
|
+
oversight anyone can route around. A synthetic `beforeinput` or `InputEvent` is
|
|
84
|
+
untrusted, so the browser refuses to run it through the editing pipeline, and
|
|
85
|
+
`setRangeText` is the standard API that changes the text while bypassing the
|
|
86
|
+
history entirely. Every editor that supports undo on a textarea uses this call.
|
|
87
|
+
|
|
88
|
+
Where it is missing, the edit still happens: `replaceThroughPipeline` falls back
|
|
89
|
+
to `setRangeText`, dispatches the `input` event the browser would have dispatched,
|
|
90
|
+
and returns `'direct'` instead of `'pipeline'`. The caller is told plainly that the
|
|
91
|
+
history did not get the edit, so a host can decide whether that matters.
|
|
92
|
+
`canEditThroughPipeline()` reports which environment you are in, `EditOutcome` is
|
|
93
|
+
`'pipeline' | 'direct' | 'unchanged'`, and `setValue(next, preserveHistory)` is the
|
|
94
|
+
one place that assigns `value` outright — for loading a different document, where
|
|
95
|
+
clearing the history is the right answer rather than a casualty.
|
|
96
|
+
|
|
97
|
+
`undoField` and `redoField` are thin wrappers over `execCommand('undo'/'redo')`.
|
|
98
|
+
They report only whether the call was possible, never whether anything was
|
|
99
|
+
actually undone, because no browser exposes that.
|
|
100
|
+
|
|
101
|
+
## Layer alignment
|
|
102
|
+
|
|
103
|
+
The painted layer draws the same characters as the field, behind it and inert
|
|
104
|
+
(`aria-hidden`, `pointer-events: none`, `user-select: none`), and the field's own
|
|
105
|
+
text is transparent with a visible caret. Two elements showing one document is
|
|
106
|
+
only stable if they cannot disagree about where a glyph goes, so:
|
|
107
|
+
|
|
108
|
+
**One typography, shared.** `.litearea-layer` and `.litearea-input` share a single
|
|
109
|
+
rule, and the mirror copies the same computed properties. Adding a text property
|
|
110
|
+
to one of them and not the other is the only way to break the stylesheet.
|
|
111
|
+
|
|
112
|
+
**The layer may change three things.** Colour, background, and `text-decoration`.
|
|
113
|
+
Anything that changes an advance — another font, a weight, letter spacing, a font
|
|
114
|
+
feature — slides the paint off the character it belongs to, and it does so by a
|
|
115
|
+
different amount on every character, which reads as a rendering glitch rather than
|
|
116
|
+
as a styling mistake. That is why the box declares one monospace face and refuses
|
|
117
|
+
ligatures: a ligature draws one glyph in the layer where the field draws two, so
|
|
118
|
+
every character after it would be painted in the wrong place.
|
|
119
|
+
|
|
120
|
+
**The scrollbar is compensated, not ignored.** When the box is clamped to its
|
|
121
|
+
maximum height the field grows a scrollbar, and a scrollbar narrows the text. If
|
|
122
|
+
the layer kept the full width it would wrap differently from the field and every
|
|
123
|
+
colour would slide off its character. So the field's measured scrollbar width is
|
|
124
|
+
published as `--litearea-scrollbar` and added to the layer's own right padding —
|
|
125
|
+
and it is read *after* the overflow is applied, because that is when a scrollbar
|
|
126
|
+
exists and therefore when there is a width to report.
|
|
127
|
+
|
|
128
|
+
**Wrapping is not maintained by positioning.** `buildSegments` collects every
|
|
129
|
+
range boundary — every token edge, decoration edge, and diagnostic edge — as a
|
|
130
|
+
cut point, then emits one segment per interval, merging neighbours whose
|
|
131
|
+
presentation is identical. The renderer appends those spans back to back with no
|
|
132
|
+
positioning at all: the characters lay out where the characters lay out, and the
|
|
133
|
+
colours follow. Alignment stops being something the code maintains and becomes
|
|
134
|
+
something the layout cannot get wrong. The traditional alternative is three
|
|
135
|
+
overlay layers, one per range list, and it does not work — each would need its own
|
|
136
|
+
idea of where a range starts, and a span positioned by measuring drifts the moment
|
|
137
|
+
the font, the wrapping, or the padding differs even slightly from what was
|
|
138
|
+
measured.
|
|
139
|
+
|
|
140
|
+
One segment shows one squiggle: the loudest diagnostic covering it wins, because
|
|
141
|
+
two underlines on the same characters only make a messier line. The layer is also
|
|
142
|
+
scrolled by copying the field's `scrollTop`/`scrollLeft` rather than by a
|
|
143
|
+
transform, which is more reliable than a transform that can leave text on a half
|
|
144
|
+
pixel.
|
|
145
|
+
|
|
146
|
+
## The mirror
|
|
147
|
+
|
|
148
|
+
Two measurements are needed and the field cannot supply either:
|
|
149
|
+
|
|
150
|
+
- **How tall the content is.** A textarea can report `scrollHeight`, but not
|
|
151
|
+
while its own height is being changed: asking for it means collapsing the
|
|
152
|
+
element first, which reflows the page and makes the box flicker on every
|
|
153
|
+
keystroke.
|
|
154
|
+
- **Where the caret is.** A textarea has no API for it at all.
|
|
155
|
+
|
|
156
|
+
So a second element is kept offscreen with the field's exact typography and box,
|
|
157
|
+
and the question is asked of it. One mirror, not two, because both jobs are
|
|
158
|
+
measurements of text with the same copied styles and both are wrong in the same
|
|
159
|
+
way if a single property is missed. It uses `visibility: hidden` rather than
|
|
160
|
+
`display: none`, because a hidden element still lays out and a removed one does
|
|
161
|
+
not.
|
|
162
|
+
|
|
163
|
+
**The property list is deliberately exhaustive**, not "the ones that seemed to
|
|
164
|
+
matter": font family, size, weight, style, stretch, variant ligatures, kerning,
|
|
165
|
+
feature settings, line height, letter spacing, word spacing, text transform,
|
|
166
|
+
indent, align, direction, tab size, white space, overflow wrap, word break,
|
|
167
|
+
hyphens, padding, border widths and styles, and box sizing. A missing
|
|
168
|
+
`letter-spacing` moves the caret a fraction of a character per character, so the
|
|
169
|
+
popup drifts further off the longer the line is — a bug that looks like a
|
|
170
|
+
placement problem and is a copied-style problem.
|
|
171
|
+
|
|
172
|
+
The width is the interesting part. It has to be the field's **content** width or
|
|
173
|
+
text wraps in one and not the other, and the field's `clientWidth` is its content
|
|
174
|
+
plus padding but *not* its border, while the mirror is `border-box`. So the
|
|
175
|
+
borders are added back, and the scrollbar is deliberately left out: a field
|
|
176
|
+
clamped to its maximum height has one, and its text wraps inside the narrower area
|
|
177
|
+
above it.
|
|
178
|
+
|
|
179
|
+
**The line height is measured, not assumed.** `line-height: normal` is a real and
|
|
180
|
+
common value and cannot be parsed as a number, so one line of text is laid out and
|
|
181
|
+
its height taken; a font that reports nothing usable falls back to 1.2 × the font
|
|
182
|
+
size.
|
|
183
|
+
|
|
184
|
+
**The trailing-newline sentinel.** A `white-space: pre-wrap` element whose content
|
|
185
|
+
ends in `\n` does not lay out a final empty line — the newline breaks the line and
|
|
186
|
+
nothing follows it — so the measured height comes back one line short, and the box
|
|
187
|
+
grows a scrollbar exactly when the user presses Enter at the end. A zero-width
|
|
188
|
+
space after the newline gives that last line something to be.
|
|
189
|
+
|
|
190
|
+
**The caret marker technique.** To find the caret, the mirror is rebuilt with the
|
|
191
|
+
text before the caret as a text node, the character *after* the caret inside a
|
|
192
|
+
`white-space: pre` span, and the rest after it. Everything before the caret lays
|
|
193
|
+
out normally, so the marker lands exactly where the next glyph will be, which is
|
|
194
|
+
where the caret is. A span that could wrap would let the marker jump to the next
|
|
195
|
+
line on its own, which is exactly the wrong answer at a line end. At the end of the
|
|
196
|
+
document a zero-width space stands in for the next character. The field's own
|
|
197
|
+
scroll offsets are then subtracted, because the caret's position on screen is what
|
|
198
|
+
the popup is placed against, and a scrolled field moves its text without moving
|
|
199
|
+
its border box. The mirror is rebuilt rather than patched: it is offscreen, so
|
|
200
|
+
measuring it is cheap, while keeping incremental DOM in step with a fast typist is
|
|
201
|
+
not.
|
|
202
|
+
|
|
203
|
+
**The unmounted trap.** An element that is not in the document has no layout, so
|
|
204
|
+
its width is zero, the mirror wraps at every character, and the measurement comes
|
|
205
|
+
back several times too tall. That wrong height then has to be corrected on the
|
|
206
|
+
next keystroke, which is what a box that jumps on first focus actually is. So an
|
|
207
|
+
unmounted field is skipped rather than measured (`!isConnected || clientWidth ===
|
|
208
|
+
0`), a `ResizeObserver` installed at construction performs the first real
|
|
209
|
+
measurement as soon as there is a width to measure against, and `createEditor`
|
|
210
|
+
re-measures synchronously after mounting so that the very first frame is already
|
|
211
|
+
right. A web font arriving later changes every metric the mirror copied, so
|
|
212
|
+
`document.fonts.ready` triggers one refresh when it lands — without it the editor
|
|
213
|
+
is a few pixels off until something else causes a re-layout.
|
|
214
|
+
|
|
215
|
+
## Auto-sizing
|
|
216
|
+
|
|
217
|
+
The decision procedure, in the order it runs:
|
|
218
|
+
|
|
219
|
+
1. With `autoGrow: false`, only adopt the mirror and return. Caret geometry still
|
|
220
|
+
needs the mirror to be shaped like the field even when the height is the host's
|
|
221
|
+
business; the stylesheet gives the field `resize: vertical`.
|
|
222
|
+
2. `chrome` is the vertical padding plus the vertical borders, which every height
|
|
223
|
+
includes; `lineHeight` comes from the mirror.
|
|
224
|
+
3. `minPx = max(minHeight ?? 0, lineHeight × minRows + chrome)` — the floor is
|
|
225
|
+
whichever of the two is larger.
|
|
226
|
+
4. `maxCandidate = min(maxHeight ?? ∞, maxRows ? lineHeight × maxRows + chrome : ∞)`,
|
|
227
|
+
then `maxPx = max(maxCandidate, minPx)`. A maximum below the minimum is a host
|
|
228
|
+
mistake, and honouring it would make the box smaller than the host was told it
|
|
229
|
+
could be.
|
|
230
|
+
5. `content = mirror.contentHeight(field, text)` — the height the text wants with
|
|
231
|
+
the full width available, and no scrollbar to narrow it.
|
|
232
|
+
6. `wanted = clamp(max(content, minPx), minPx, maxPx)`; `overflow` becomes `'auto'`
|
|
233
|
+
when the content exceeds what was granted by more than half a pixel, and
|
|
234
|
+
`'hidden'` otherwise.
|
|
235
|
+
7. Write the height only when it moved by more than half a pixel, write
|
|
236
|
+
`overflow-y` only when it changed, and then read the scrollbar width (now that
|
|
237
|
+
one may exist) and publish it.
|
|
238
|
+
|
|
239
|
+
The mirror has no scrollbar, which is what makes step 5 the number that decides
|
|
240
|
+
whether a scrollbar is needed at all. That is also why **wrapping has to agree
|
|
241
|
+
with the layer**: the decision to clamp is a claim that the text fits in the width
|
|
242
|
+
the layer will be given, so the width the mirror measures at, the width the field
|
|
243
|
+
wraps at, and the width the layer wraps at are the same width — including the
|
|
244
|
+
scrollbar compensation above. `minRows` is additionally written to the textarea's
|
|
245
|
+
native `rows` attribute, so the box has the right height on the very first frame,
|
|
246
|
+
before any measurement, and in a document where the script never runs at all.
|
|
247
|
+
|
|
248
|
+
## Pure engine, thin DOM
|
|
249
|
+
|
|
250
|
+
`src/core/` imports nothing from `src/dom/`, and nothing in it touches the DOM. Text
|
|
251
|
+
and a grammar go in, values come out:
|
|
252
|
+
|
|
253
|
+
| Testable in plain Node | Needs a browser |
|
|
254
|
+
| --- | --- |
|
|
255
|
+
| `scan` / `inspect`: the paint, the diagnostics, the decorations, the analysis | Whether an edit is undoable |
|
|
256
|
+
| `complete` / `applyCompletion`: eligibility, source stability, the recomputed range, ranking, the edit | Where the caret ends up after an edit |
|
|
257
|
+
| `rank`, `fuzzyMatch`, `highlightSegments` | Whether the layer and the field really line up |
|
|
258
|
+
| `buildSegments`: the merge order | Whether the box has a scrollbar |
|
|
259
|
+
| `resolveHover` and the precedence | Anything about font metrics, wrapping, or scrollbar widths |
|
|
260
|
+
| `defineVocabulary`, `lineStarts`, `wordInfoAt`, `tokenAt` | |
|
|
261
|
+
|
|
262
|
+
Everything in the left column has a decision in it, and a decision is what a unit
|
|
263
|
+
test asserts. Everything in the right column is a fact about a renderer. The four
|
|
264
|
+
of them are exactly why `scripts/browser-check.mjs` exists: a textarea's undo
|
|
265
|
+
stack has no API to read and no way to fake, so the only honest test is to make an
|
|
266
|
+
edit and undo it in a browser that has a real one.
|
|
267
|
+
|
|
268
|
+
The harness bundles the **shipped** `dist/` files rather than `src/` — so what it
|
|
269
|
+
drives is the artifact a host installs — inlines them as an IIFE (Chromium refuses
|
|
270
|
+
to fetch an ES module from a `file://` URL), and drives the public API and the
|
|
271
|
+
public DOM only: it mounts editors, types through `execCommand('insertText')`,
|
|
272
|
+
presses keys, dispatches a `mousedown` on a row, and hovers a squiggle. The page
|
|
273
|
+
reports through `document.title`, with `error` and `unhandledrejection` listeners
|
|
274
|
+
that write to the same channel, because a page whose only output channel is its
|
|
275
|
+
title has to report its own failures through that channel — otherwise a syntax
|
|
276
|
+
error in the bundle shows up as an unchanged title and no evidence at all. The
|
|
277
|
+
browser's stdout is written to files rather than read through a pipe, because a
|
|
278
|
+
browser's stdout cannot be captured through piped stdio in a confined environment
|
|
279
|
+
and comes back empty in a way that looks exactly like the page having produced no
|
|
280
|
+
output.
|
|
281
|
+
|
|
282
|
+
The part of that harness worth copying is the **negative control**. Before it
|
|
283
|
+
tests that a completion can be undone, it asserts that a direct `value` assignment
|
|
284
|
+
is **not** undoable:
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
control.value = 'abc'
|
|
288
|
+
control.focus()
|
|
289
|
+
control.value = 'abcd'
|
|
290
|
+
document.execCommand('undo')
|
|
291
|
+
// control.value must still be 'abcd'
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Without it, a passing undo test would only show that `execCommand('undo')` returned
|
|
295
|
+
true. The check skips cleanly, with exit 0, when no Chromium-based browser is
|
|
296
|
+
present, so a clean CI runner does not fail for it; `LITEAREA_REQUIRE=1` turns the
|
|
297
|
+
skip into a failure for the environments that must not skip.
|
|
298
|
+
|
|
299
|
+
The DOM layer itself is deliberately thin: `LiteArea` is the only class that holds
|
|
300
|
+
state, and every other module in `src/dom/` is one job — `editing` the
|
|
301
|
+
undo-preserving write, `mirror` the measurement, `overlay` the paint, `popup` the
|
|
302
|
+
list, `tooltip` the hover, `support` the one place that asks the environment a
|
|
303
|
+
question. `createEditor(target, options)` is the class plus "append it and measure
|
|
304
|
+
again", which is all most hosts need.
|
|
305
|
+
|
|
306
|
+
## What was rejected
|
|
307
|
+
|
|
308
|
+
| Rejected | Why |
|
|
309
|
+
| --- | --- |
|
|
310
|
+
| A controlled component (state → `value` → re-render) | This is the bug the library exists to fix: assigning `value` clears the undo history and resets the caret, and re-rendering text to reposition the caret is a race the browser wins. A grammar worth writing is worth keeping the browser's undo stack for |
|
|
311
|
+
| `contenteditable` | It makes the document a DOM tree that the browser edits in ways you do not control — nested elements, `<br>` for a blank line, pasted HTML — so the value would have to be re-serialised on every keystroke, which is the same re-render problem with more failure modes and no native undo semantics worth having |
|
|
312
|
+
| A second highlighting layer per range list (one for scopes, one for decorations, one for squiggles) | Each layer would have to position its own spans, so each would need its own idea of where a range starts, and a span positioned by measuring drifts as soon as the font, the wrapping, or the padding is even slightly different from what was measured. One merged stream cannot drift because nothing is positioned |
|
|
313
|
+
| Measuring the live field (`scrollHeight` after collapsing it) | It reflows the page and flickers on every keystroke — the measurement that is supposed to decide the height is what makes the height unstable. The offscreen mirror pays one extra layout and keeps the field's geometry out of the question |
|
|
314
|
+
| A bundled grammar (a default language, or a language registry) | The claim of the library is that the core knows no syntax. A default grammar would make that claim false, and it would be the thing every host inherited. The two reference grammars are importable and deliberately outside `src/core/` |
|
|
315
|
+
| Synthetic `beforeinput`/`InputEvent` edits | Untrusted, so the browser will not run them through the editing pipeline: the text changes and the history does not, which is worse than a documented `'direct'` fallback because it fails silently |
|
|
316
|
+
| Tracking the caret as a character index in one module and a screen position in another | Every position that crosses a module boundary is a character offset; pixels enter only in `src/dom/`, only to place a floating element, and never travel back inward. Two coordinate systems for one caret is what produced the range that "had already moved" |
|