@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 citisen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
# litearea
|
|
2
|
+
|
|
3
|
+
[English](README.md) | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
**A code editor over a plain `<textarea>`: undo and redo that work, highlighting from rules you write, and VSCode-shaped completion — with no runtime dependency.**
|
|
6
|
+
|
|
7
|
+
`@citisen/litearea` is a library, not a plugin. Nothing here registers with an
|
|
8
|
+
interface, reads a settings namespace, or knows what a favicon is. It is a small
|
|
9
|
+
engine that turns a string and a grammar into tokens, diagnostics, decorations,
|
|
10
|
+
and a list of suggestions, plus a thin DOM layer that puts all of that behind a
|
|
11
|
+
real textarea. The React binding is optional and separate.
|
|
12
|
+
|
|
13
|
+
The interesting decision is what the library does *not* do: it does not own the
|
|
14
|
+
text. The textarea owns it, the browser's editing pipeline edits it, and the
|
|
15
|
+
library only ever reads it.
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
The two DeepSeek Harness plugins this came out of — `dsh-font` and `dsh-sentry` —
|
|
20
|
+
each contain a small DSL, and each grew its own editor over a textarea. Both went
|
|
21
|
+
wrong in the same three ways:
|
|
22
|
+
|
|
23
|
+
- the completion list felt like it was fighting the typist,
|
|
24
|
+
- Ctrl+Z stopped working as soon as a suggestion was accepted,
|
|
25
|
+
- the caret jumped to the end while the user was editing the middle of a line.
|
|
26
|
+
|
|
27
|
+
Those were not three bugs. They were one bug with three faces: the text lived in
|
|
28
|
+
component state, was written back into the textarea on every keystroke, and was
|
|
29
|
+
then read three separate times — once to paint, once to diagnose, once to
|
|
30
|
+
suggest — by three passes that could disagree.
|
|
31
|
+
|
|
32
|
+
Assigning a textarea's `value` from script is not an edit. It replaces the
|
|
33
|
+
element's content, and with it the browser's undo stack, and it resets the
|
|
34
|
+
selection to the end. That single call is why undo was gone *and* why the caret
|
|
35
|
+
moved. The third symptom is the three passes: a word could be painted as a valid
|
|
36
|
+
value while the completer called it unknown and the squiggle pointed at a range
|
|
37
|
+
that had already moved.
|
|
38
|
+
|
|
39
|
+
So this library makes the opposite choices, and each one is load-bearing:
|
|
40
|
+
|
|
41
|
+
- **Undo and redo survive, because the DOM owns the text.** The field is written
|
|
42
|
+
once, at construction, before any listener exists. Every edit the library makes
|
|
43
|
+
on the user's behalf goes through `document.execCommand('insertText')`, so the
|
|
44
|
+
browser records it in its own undo stack — one edit, undoable with the
|
|
45
|
+
browser's own Ctrl+Z. There is deliberately no replacement API: a synthetic
|
|
46
|
+
`input` event is untrusted and the browser refuses to run it through the
|
|
47
|
+
editing pipeline, and `setRangeText` changes the text while bypassing the
|
|
48
|
+
history.
|
|
49
|
+
- **The caret does not move, because nothing rewrites the text under it.** The
|
|
50
|
+
layer reads the field; it never writes it. The only selection writes are
|
|
51
|
+
`setSelectionRange` calls a completion asked for.
|
|
52
|
+
- **One parse per text.** `inspect(text, grammar)` runs once, and the paint, the
|
|
53
|
+
squiggles, the semantic marks, the completion list, and the hover tooltip all
|
|
54
|
+
read that same value. They cannot drift because there is nothing to drift from.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npm install @citisen/litearea
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Three entry points and one stylesheet:
|
|
63
|
+
|
|
64
|
+
| Import | What it is |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `@citisen/litearea` | The pure engine plus the DOM layer (`createEditor`, `LiteArea`) |
|
|
67
|
+
| `@citisen/litearea/react` | The React binding (`LiteAreaEditor`) |
|
|
68
|
+
| `@citisen/litearea/grammars` | The two worked example grammars |
|
|
69
|
+
| `@citisen/litearea/styles.css` | The same stylesheet the editor injects, for hosts that link CSS |
|
|
70
|
+
|
|
71
|
+
React is an optional peer dependency (`react >= 18`) and the only peer. The
|
|
72
|
+
package has no runtime dependencies at all. The stylesheet is injected into the
|
|
73
|
+
document once, on the first editor, unless `injectStyles: false` is passed.
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
Vanilla, with a grammar small enough to read:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { createEditor, defineGrammar, defineVocabulary } from '@citisen/litearea'
|
|
81
|
+
|
|
82
|
+
// A vocabulary declares the four facts a closed word set needs — the words, the
|
|
83
|
+
// scope they are painted with, the message a non-member earns, and the
|
|
84
|
+
// documentation a hover shows — which is why they cannot drift apart.
|
|
85
|
+
const COLORS = defineVocabulary({
|
|
86
|
+
id: 'color',
|
|
87
|
+
words: ['red', 'green', 'blue'],
|
|
88
|
+
unknownMessage: '"{word}" is not a colour — expected {allowed}.',
|
|
89
|
+
docs: { red: 'The default swatch.' },
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const grammar = defineGrammar({
|
|
93
|
+
id: 'swatch',
|
|
94
|
+
// Rules are tried in order at every position; the first match wins.
|
|
95
|
+
rules: [
|
|
96
|
+
{ kind: 'match', scope: 'comment', pattern: /#[^\n]*/ },
|
|
97
|
+
{ kind: 'words', words: COLORS, unknown: {} },
|
|
98
|
+
],
|
|
99
|
+
compose: [
|
|
100
|
+
{
|
|
101
|
+
id: 'color',
|
|
102
|
+
range: (context) => context.word,
|
|
103
|
+
items: () =>
|
|
104
|
+
['red', 'green', 'blue'].map((color) => ({ label: color, kind: 'color' })),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const editor = createEditor(document.querySelector('#editor')!, {
|
|
110
|
+
grammar,
|
|
111
|
+
value: 'red # a comment',
|
|
112
|
+
placeholder: 'red, green, blue',
|
|
113
|
+
onChange: (value) => {
|
|
114
|
+
// Reports the user's own edits. A write the library performs — a completion, or
|
|
115
|
+
// setValue below — is marked before it happens and is not reported back.
|
|
116
|
+
console.log(value)
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// A host-driven write. `true` makes it one undoable edit, so Ctrl+Z brings the
|
|
121
|
+
// previous text back; the default is a plain assignment, which clears the history.
|
|
122
|
+
editor.setValue('green', true)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
React, with the same `grammar` object:
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
import * as React from 'react'
|
|
129
|
+
import type { LiteArea } from '@citisen/litearea'
|
|
130
|
+
import { LiteAreaEditor } from '@citisen/litearea/react'
|
|
131
|
+
import { grammar } from './swatch-grammar'
|
|
132
|
+
|
|
133
|
+
export function SwatchEditor() {
|
|
134
|
+
const [text, setText] = React.useState('')
|
|
135
|
+
const editorRef = React.useRef<LiteArea | null>(null)
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<>
|
|
139
|
+
<LiteAreaEditor
|
|
140
|
+
grammar={grammar}
|
|
141
|
+
defaultValue="red # a comment"
|
|
142
|
+
onChange={setText}
|
|
143
|
+
sizing={{ minRows: 2, maxRows: 10 }}
|
|
144
|
+
editorRef={(editor) => {
|
|
145
|
+
editorRef.current = editor ?? null
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
<button type="button" onClick={() => editorRef.current?.undo()}>
|
|
149
|
+
Undo
|
|
150
|
+
</button>
|
|
151
|
+
<pre>{text}</pre>
|
|
152
|
+
</>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`LiteAreaEditor` renders one empty `div` and mounts the imperative editor inside
|
|
158
|
+
it. It never passes the text through React, for the reason in [Why](#why): doing
|
|
159
|
+
so is what breaks undo. `defaultValue` is the prop to use for the starting text;
|
|
160
|
+
`value` exists for a host that wants to write text (a Reset button, loading a
|
|
161
|
+
different document), it is written through the editing pipeline so it stays
|
|
162
|
+
undoable, and it does not call `onChange` — the caller already knows what it wrote.
|
|
163
|
+
|
|
164
|
+
## What it does
|
|
165
|
+
|
|
166
|
+
| Feature | The mechanism behind it |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| Undo and redo | The field is written once, at construction. Every library edit goes through `execCommand('insertText')`, so the browser's own history records it |
|
|
169
|
+
| A caret that stays put | Nothing rewrites the text under it; the only selection writes are the ones a completion asked for |
|
|
170
|
+
| Custom highlighting | `rules` paint scopes; a scope becomes the class `litearea-scope-<scope>`, and the stylesheet decides the rest. The core ships no syntax at all |
|
|
171
|
+
| Completion | Sources over a range recomputed from the caret on every filter, tiered fuzzy ranking, a documentation panel, commit characters, and mouse picks that do not blur the field |
|
|
172
|
+
| Diagnostics | A vocabulary's rejection, declarative `checks`, and a `validate` hook, merged, deduplicated, and painted as squiggles in four severities |
|
|
173
|
+
| Hover tooltips | `resolveHover`: a diagnostic outranks everything else; otherwise a decoration's title and the grammar's own `describe` are shown together |
|
|
174
|
+
| Semantic marks | `decorate` returns ranges that are deliberately not tokens, painted as `litearea-dec-<kind>` and recomputed without re-lexing |
|
|
175
|
+
| Auto-sizing | An offscreen mirror is measured instead of the live field; height and overflow are written, and the measured scrollbar width is published for the layer |
|
|
176
|
+
| One parse per text | `inspect` produces tokens, diagnostics, decorations, and the analysis together, cached on the text; the paint is skipped when text and analysis are unchanged |
|
|
177
|
+
| Keyboard and a11y | `role="combobox"`, `aria-expanded`, `aria-activedescendant`, `aria-invalid`, `aria-label`, and a listbox with real row ids |
|
|
178
|
+
| Weight | No runtime dependencies, no CodeMirror, no Monaco, no virtual DOM in the engine |
|
|
179
|
+
|
|
180
|
+
## Writing a grammar
|
|
181
|
+
|
|
182
|
+
A grammar is one object. This one describes a tiny form schema:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
# the signup form
|
|
186
|
+
form signup
|
|
187
|
+
text email required
|
|
188
|
+
text password secret "at least 12 characters"
|
|
189
|
+
number age optional
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { defineGrammar, defineVocabulary } from '@citisen/litearea'
|
|
194
|
+
|
|
195
|
+
const FIELD_TYPES = ['text', 'number', 'bool'] as const
|
|
196
|
+
const OPTION_WORDS = ['required', 'optional', 'secret'] as const
|
|
197
|
+
|
|
198
|
+
const TYPES = defineVocabulary({
|
|
199
|
+
id: 'field-type',
|
|
200
|
+
words: FIELD_TYPES,
|
|
201
|
+
scope: 'field.type',
|
|
202
|
+
unknownMessage: '"{word}" is not a field type — expected {allowed}.',
|
|
203
|
+
docs: {
|
|
204
|
+
text: { detail: 'one line of text', body: 'The only type that can be `secret`.' },
|
|
205
|
+
number: { detail: 'a number' },
|
|
206
|
+
bool: { detail: 'yes or no' },
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const OPTIONS = defineVocabulary({
|
|
211
|
+
id: 'option',
|
|
212
|
+
words: OPTION_WORDS,
|
|
213
|
+
scope: 'option',
|
|
214
|
+
docs: {
|
|
215
|
+
required: { detail: 'cannot be left empty' },
|
|
216
|
+
optional: { detail: 'may be left empty' },
|
|
217
|
+
secret: { detail: 'never shown again' },
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
export const formSchema = defineGrammar({
|
|
222
|
+
id: 'form-schema',
|
|
223
|
+
name: 'form schema',
|
|
224
|
+
// A name may contain a hyphen, so `email-address` is ONE word: completion
|
|
225
|
+
// replaces the whole name and a double click selects all of it.
|
|
226
|
+
wordChars: /[\p{L}\p{N}_-]/u,
|
|
227
|
+
rules: [
|
|
228
|
+
{ kind: 'match', scope: 'comment', pattern: /#[^\n]*/ },
|
|
229
|
+
{ kind: 'match', scope: 'keyword', pattern: /form/, when: { prevNot: '\\w' } },
|
|
230
|
+
{
|
|
231
|
+
kind: 'match',
|
|
232
|
+
scope: 'form.name',
|
|
233
|
+
pattern: /[A-Za-z][\w-]*/,
|
|
234
|
+
when: { after: ['keyword'] },
|
|
235
|
+
},
|
|
236
|
+
// A row starts with a field type. Anything else at the head of a line is
|
|
237
|
+
// reported rather than quietly falling through to plain text.
|
|
238
|
+
{ kind: 'words', words: TYPES, when: { firstOnLine: true }, unknown: {} },
|
|
239
|
+
{ kind: 'words', words: OPTIONS },
|
|
240
|
+
{ kind: 'match', scope: 'name', pattern: /[A-Za-z][\w-]*/, when: { after: ['field.type'] } },
|
|
241
|
+
{ kind: 'region', scope: 'note', begin: /"/, end: /"/, unclosed: { severity: 'warning' } },
|
|
242
|
+
{ kind: 'match', scope: 'invalid', pattern: /\S+/ },
|
|
243
|
+
],
|
|
244
|
+
fallbackScope: 'text',
|
|
245
|
+
compose: [
|
|
246
|
+
{
|
|
247
|
+
id: 'field-type',
|
|
248
|
+
// `firstWord` rather than `firstOnLine`: the list has to stay eligible while
|
|
249
|
+
// the first word is being spelled, not only when the line is still empty.
|
|
250
|
+
when: (context) => context.firstWord,
|
|
251
|
+
range: (context) => context.word,
|
|
252
|
+
items: () =>
|
|
253
|
+
FIELD_TYPES.map((type) => ({
|
|
254
|
+
label: type,
|
|
255
|
+
append: ' ',
|
|
256
|
+
kind: 'type',
|
|
257
|
+
detail: TYPES.entryFor(type)?.detail,
|
|
258
|
+
documentation: TYPES.entryFor(type)?.body,
|
|
259
|
+
})),
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: 'option',
|
|
263
|
+
// An option follows a name, and the name may itself still be half-typed.
|
|
264
|
+
when: (context) =>
|
|
265
|
+
context.tokens.some(
|
|
266
|
+
(token) =>
|
|
267
|
+
token.line === context.line.number &&
|
|
268
|
+
token.scope === 'name' &&
|
|
269
|
+
token.to <= context.caret,
|
|
270
|
+
),
|
|
271
|
+
range: (context) => context.word,
|
|
272
|
+
items: () =>
|
|
273
|
+
OPTION_WORDS.map((word) => ({
|
|
274
|
+
label: word,
|
|
275
|
+
kind: 'option',
|
|
276
|
+
detail: OPTIONS.entryFor(word)?.detail,
|
|
277
|
+
})),
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
describe: (context) => {
|
|
281
|
+
const token = context.token
|
|
282
|
+
if (token === undefined) return undefined
|
|
283
|
+
if (token.scope === 'note') return { title: 'note', body: 'Shown under the field.' }
|
|
284
|
+
const entry =
|
|
285
|
+
token.scope === 'field.type' ? TYPES.entryFor(token.text) : OPTIONS.entryFor(token.text)
|
|
286
|
+
return entry === undefined ? undefined : { title: token.text, body: entry.body }
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
That is the whole language: eight rules, two vocabularies, two completion
|
|
292
|
+
sources, and a hover. `TYPES` is painted through a vocabulary, so a word in the
|
|
293
|
+
wrong place earns one message instead of being silently plain; `OPTIONS` is a
|
|
294
|
+
plain rule that never rejects, so a rule lower down still gets its turn at a word
|
|
295
|
+
it does not know.
|
|
296
|
+
|
|
297
|
+
The complete reference — every field of every rule kind, precedence, the
|
|
298
|
+
diagnostic vocabulary, and an end-to-end walkthrough that adds `analyze`,
|
|
299
|
+
`checks`, and `validate` to this same language — is in
|
|
300
|
+
[docs/grammar.md](docs/grammar.md).
|
|
301
|
+
|
|
302
|
+
## The two reference grammars
|
|
303
|
+
|
|
304
|
+
The core ships no syntax: there is no built-in language, no language identifier
|
|
305
|
+
to switch on, and nothing in `src/core/` that knows what a font stack is. The two
|
|
306
|
+
grammars in `@citisen/litearea/grammars` exist to check that claim rather than
|
|
307
|
+
merely assert it. Both are real DSLs from the two plugins this library came out
|
|
308
|
+
of, and both are importable by a host that does not want to retype a language
|
|
309
|
+
that already exists:
|
|
310
|
+
|
|
311
|
+
| Grammar | The language | What it exercises |
|
|
312
|
+
| --- | --- | --- |
|
|
313
|
+
| `dshSentryStyleGrammar()` | dsh-sentry's appearance document: one line per session state, then positional values or `key=value` pairs | A first-word vocabulary with rejection, an analysis that both fills slots and records the problems it found, a declarative `check`, two completion sources using `sortText`, and hover from vocabulary documentation |
|
|
314
|
+
| `dshFontQueryGrammar()` | dsh-font's font query: a CSS font-family list with the weight written beside the family it belongs to | Quote handling in the lexical rules, a `scope` function that reads the analysis, dynamic vocabularies resolved from the host's installed catalogue, multi-word phrases, completion that inserts *before* an entry, and a semantic decoration |
|
|
315
|
+
|
|
316
|
+
Neither is built in. Nothing in `src/core/` imports them, no option switches them
|
|
317
|
+
on, and an editor constructed without one of them has no syntax whatsoever.
|
|
318
|
+
|
|
319
|
+
Two decisions in `dshSentryStyleGrammar` are worth stating, because both were
|
|
320
|
+
found by comparing the grammar against the parser it edits for:
|
|
321
|
+
|
|
322
|
+
- **It is deliberately stricter than the host parser.** `parseStyle` in the
|
|
323
|
+
plugin does no value checking for a known option: it writes `shape=bogus` into
|
|
324
|
+
the rule and lets `resolveLook` substitute the shipped default later, so a typo
|
|
325
|
+
shows up as an icon that simply never changed. The grammar reports it — as a
|
|
326
|
+
warning rather than an error, because the document still works, it just does
|
|
327
|
+
not mean what it says.
|
|
328
|
+
- **It does not accept `fallback` as a state.** The plugin's own module comment
|
|
329
|
+
shows a `fallback none` line, but `STYLE_STATES` holds only the four states and
|
|
330
|
+
`fallback` is derived internally from `STYLE_FALLBACK_LOOK`; it has never been
|
|
331
|
+
parseable. That comment is stale, and copying it into the grammar would have
|
|
332
|
+
made the editor disagree with the parser it edits for.
|
|
333
|
+
|
|
334
|
+
## Auto-sizing
|
|
335
|
+
|
|
336
|
+
Five options, one behaviour each:
|
|
337
|
+
|
|
338
|
+
| Option | Default | What it does |
|
|
339
|
+
| --- | --- | --- |
|
|
340
|
+
| `autoGrow` | `true` | The height follows the content. With `false` the field gets `resize: vertical` and the host owns the height |
|
|
341
|
+
| `minRows` | `1` | The fewest lines to show. Written to the textarea's native `rows` attribute, so the first frame is already right |
|
|
342
|
+
| `maxRows` | — | The most lines to show before a scrollbar appears |
|
|
343
|
+
| `minHeight` | — | A minimum height in pixels, in addition to `minRows` |
|
|
344
|
+
| `maxHeight` | — | A maximum height in pixels, in addition to `maxRows` |
|
|
345
|
+
|
|
346
|
+
The three behaviours the brief above adds up to:
|
|
347
|
+
|
|
348
|
+
- **It grows and shrinks to fit, and shows no scrollbar while it fits.** When the
|
|
349
|
+
measured content height is under the maximum, that height is written to the
|
|
350
|
+
field and `overflow-y` stays `hidden`.
|
|
351
|
+
- **A maximum introduces a scrollbar.** Past the clamp the height is fixed and
|
|
352
|
+
`overflow-y` becomes `auto`. A scrollbar narrows the text, so the measured
|
|
353
|
+
scrollbar width is published as `--litearea-scrollbar` and added to the layer's
|
|
354
|
+
own padding, or the paint would wrap differently from the field and every
|
|
355
|
+
colour would slide off its character.
|
|
356
|
+
- **A minimum sets the floor**: `minHeight`, or `minRows` worth of line boxes,
|
|
357
|
+
plus the field's vertical padding and border. A maximum below the minimum is
|
|
358
|
+
raised to the minimum rather than honoured, because honouring it would make the
|
|
359
|
+
box smaller than the host said it could be.
|
|
360
|
+
|
|
361
|
+
An unmounted field is not measured at all: before the element is in the document
|
|
362
|
+
it has no layout, so its width is zero and the measurement comes back several
|
|
363
|
+
times too tall. `createEditor` does the first measurement synchronously after
|
|
364
|
+
mounting for exactly this reason, and a host constructing `LiteArea` directly
|
|
365
|
+
should call `refresh()` after appending `editor.element`.
|
|
366
|
+
|
|
367
|
+
## Keyboard
|
|
368
|
+
|
|
369
|
+
Everything the editor intercepts, and what it deliberately leaves alone:
|
|
370
|
+
|
|
371
|
+
| Key | What it does |
|
|
372
|
+
| --- | --- |
|
|
373
|
+
| `ArrowDown` / `ArrowUp` | Move the active row by one. It stops at the ends instead of wrapping |
|
|
374
|
+
| `PageDown` / `PageUp` | Move the active row by eight |
|
|
375
|
+
| `Enter` | Accept the active row |
|
|
376
|
+
| `Tab` | Accept the active row |
|
|
377
|
+
| `Escape` | Close the list; with no list open, hide the tooltip |
|
|
378
|
+
| `Ctrl+Space` / `Cmd+Space` | Open the list, or close it when it is already open |
|
|
379
|
+
| a row's `commitCharacters` | Accept the active row and write the character after it, so the keystroke is not swallowed. The list then closes |
|
|
380
|
+
| `Ctrl+Z`, `Ctrl+Shift+Z`, `Ctrl+Y` | **Not intercepted.** These are the browser's own undo and redo on the field, which is the whole point of being uncontrolled |
|
|
381
|
+
| `Shift+Arrow`, `Home`, `End` | Not intercepted. They move the caret without an `input` event, so the editor just closes a list the caret has walked out of |
|
|
382
|
+
|
|
383
|
+
## Theming
|
|
384
|
+
|
|
385
|
+
A scope becomes a class (`litearea-scope-value-color`), a decoration kind becomes
|
|
386
|
+
a class (`litearea-dec-effective`), and a severity becomes a class
|
|
387
|
+
(`litearea-diag-error`). Dots and other punctuation fold to hyphens, so
|
|
388
|
+
`value.color` is selected as `litearea-scope-value-color` and never needs a
|
|
389
|
+
backslash.
|
|
390
|
+
|
|
391
|
+
Colours, spacing, and the type scale come from custom properties on the wrapper:
|
|
392
|
+
|
|
393
|
+
| Property | Default |
|
|
394
|
+
| --- | --- |
|
|
395
|
+
| `--litearea-font` | A monospace stack starting at `ui-monospace` |
|
|
396
|
+
| `--litearea-font-size` | `13px` |
|
|
397
|
+
| `--litearea-line-height` | `20px` |
|
|
398
|
+
| `--litearea-padding-block` / `--litearea-padding-inline` | `6px` / `10px` |
|
|
399
|
+
| `--litearea-radius` | `8px` |
|
|
400
|
+
| `--litearea-fg` / `--litearea-fg-dim` / `--litearea-fg-strong` | `#1f2328` / `#6b7280` / `#111827` |
|
|
401
|
+
| `--litearea-bg` / `--litearea-bg-raised` | `#ffffff` |
|
|
402
|
+
| `--litearea-border` / `--litearea-border-focus` | `#d8dbe0` / `#4d6bfe` |
|
|
403
|
+
| `--litearea-accent` / `--litearea-accent-soft` / `--litearea-selection` | `#4d6bfe` and two alpha variants |
|
|
404
|
+
| `--litearea-error` / `--litearea-warning` / `--litearea-info` / `--litearea-hint` | The four severity colours |
|
|
405
|
+
| `--litearea-shadow` | The floating panel's shadow |
|
|
406
|
+
| `--litearea-scope-*` | One colour per scope the two reference grammars use |
|
|
407
|
+
|
|
408
|
+
A dark scheme is applied automatically from `prefers-color-scheme`.
|
|
409
|
+
|
|
410
|
+
### A custom theme, or a custom font
|
|
411
|
+
|
|
412
|
+
Both are the same mechanism, and there are two ways to reach it. From CSS, scoped to whatever
|
|
413
|
+
you like:
|
|
414
|
+
|
|
415
|
+
```css
|
|
416
|
+
.myEditor .litearea {
|
|
417
|
+
--litearea-font: "IBM Plex Mono", ui-monospace, monospace;
|
|
418
|
+
--litearea-font-size: 12px;
|
|
419
|
+
--litearea-line-height: 18px;
|
|
420
|
+
--litearea-scope-comment: #8b919b;
|
|
421
|
+
--litearea-scope-value-color: #0f766e;
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Or per instance, without writing a stylesheet rule — which is what the `variables` option is
|
|
426
|
+
for, and the only way to give two editors on one page two different faces:
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
createEditor(target, {
|
|
430
|
+
grammar,
|
|
431
|
+
value: 'running circle blue turn 3',
|
|
432
|
+
variables: {
|
|
433
|
+
font: 'Georgia, serif', // any face: see below
|
|
434
|
+
'font-size': '15px',
|
|
435
|
+
'line-height': '26px',
|
|
436
|
+
'scope-state': '#b91c1c',
|
|
437
|
+
accent: '#c2410c',
|
|
438
|
+
'--my-brand': 'hotpink', // a name that starts with -- is used verbatim
|
|
439
|
+
},
|
|
440
|
+
})
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
A key may be written `font`, `litearea-font`, or `--litearea-font`. Calling
|
|
444
|
+
`editor.setVariables({ … })` replaces what that method set last time, so a property you drop
|
|
445
|
+
falls back to CSS rather than lingering. In React the same prop is applied live, and an inline
|
|
446
|
+
object literal is fine — the records are compared by value.
|
|
447
|
+
|
|
448
|
+
**Any font works, including a proportional one.** The question a host asks first is whether
|
|
449
|
+
alignment survives a face the editor did not choose, and it does: the painted layer, the real
|
|
450
|
+
textarea, and the offscreen mirror that measures them all read the same computed font, so they
|
|
451
|
+
wrap identically whatever it is. Monospace is the *default*, not a requirement, and a
|
|
452
|
+
proportional face will look odd for code while still being perfectly aligned.
|
|
453
|
+
|
|
454
|
+
What is not yours to change is ligatures and kerning, which are forced off. A ligature draws one
|
|
455
|
+
glyph where the field holds two characters, and the layer splits its spans wherever a diagnostic
|
|
456
|
+
or a decoration begins — so a split would leave one glyph in the field and two in the paint, at
|
|
457
|
+
different widths, and the rest of the line would slide out from under its colour. Turning them
|
|
458
|
+
off removes the possibility rather than hoping no span boundary ever lands inside a pair.
|
|
459
|
+
|
|
460
|
+
Changing those properties is safe because the layer, the field, and the mirror
|
|
461
|
+
all read them. Adding a text property to the layer alone is not: the layer may
|
|
462
|
+
change colour, background, and `text-decoration`, and nothing that moves a glyph.
|
|
463
|
+
`--litearea-scrollbar` is written by the editor; do not set it.
|
|
464
|
+
|
|
465
|
+
## Limits and honest notes
|
|
466
|
+
|
|
467
|
+
- **`document.execCommand` is deprecated and there is no replacement.** It is
|
|
468
|
+
also the only way to change a textarea's value while keeping the browser's undo
|
|
469
|
+
stack. Where it is missing, an edit still lands through `setRangeText` but the
|
|
470
|
+
history does not get it — `canEditThroughPipeline()` reports which environment
|
|
471
|
+
you are in, and `EditOutcome` tells you per edit whether it was `'pipeline'`,
|
|
472
|
+
`'direct'`, or `'unchanged'`.
|
|
473
|
+
- **The layer's typography is fixed to one monospace face and ligatures are off.**
|
|
474
|
+
A ligature draws one glyph in the layer where the field draws two, so every
|
|
475
|
+
character after it would be painted in the wrong place.
|
|
476
|
+
- **`onChange` reports the user's edits only.** Every write the library performs — a
|
|
477
|
+
completion, or `setValue` in either mode — is marked as its own before it happens,
|
|
478
|
+
so the caller is never handed its own change back. The React `value` prop goes
|
|
479
|
+
through `setValue`, so it is silent too.
|
|
480
|
+
- **`onDiagnostics` fires once when the editor mounts**, even when the document is
|
|
481
|
+
clean, and then only when the problem list really changed — compared on position,
|
|
482
|
+
code, and message. A host waiting to be told its list was clear therefore hears it.
|
|
483
|
+
- **In the React binding, `sizing`, `completion`, `hover`, `decorations`,
|
|
484
|
+
`injectStyles`, and `styleNonce` are read when the editor mounts.** Changing
|
|
485
|
+
one of them later re-renders the wrapper and nothing else. Only `grammar`,
|
|
486
|
+
`value`, and `readOnly` are re-read on every render, and only the grammar is
|
|
487
|
+
safe to rebuild on every render (`refresh()` re-resolves it without rebuilding
|
|
488
|
+
the element, which is what keeps the undo history across a language change).
|
|
489
|
+
- **The painted layer can only change colour, background, and text-decoration.**
|
|
490
|
+
Anything that changes an advance — another font, a weight, letter spacing, a
|
|
491
|
+
font feature — slides the paint off the character it belongs to by a different
|
|
492
|
+
amount on every character.
|
|
493
|
+
- **`inspect` runs the whole scan for each new text.** There is no incremental
|
|
494
|
+
re-lex, so a very large document costs a full pass per keystroke. The paint is
|
|
495
|
+
skipped when the text and the analysis are unchanged, which is what keeps
|
|
496
|
+
caret moves and hovers cheap.
|
|
497
|
+
- **Hover needs a caret hit-test.** It asks
|
|
498
|
+
`caretPositionFromPoint`/`caretRangeFromPoint` where the pointer is;
|
|
499
|
+
`hasCaretHitTest()` reports whether the environment has either. Without one the
|
|
500
|
+
tooltip simply never appears.
|
|
501
|
+
- **Nothing here is a full editor.** There are no line numbers, no search, no
|
|
502
|
+
multiple cursors, no bracket matching, no folding, no snippets, and no undo
|
|
503
|
+
button — the browser's own history is the undo stack, and `editor.undo()` and
|
|
504
|
+
`editor.redo()` are thin wrappers over it that report only whether the call was
|
|
505
|
+
possible.
|
|
506
|
+
- **Hover tooltips and the documentation panel are plain text.** There is no
|
|
507
|
+
markdown rendering and no HTML: a font family called `<b>` is shown as `<b>`.
|
|
508
|
+
- **There is no `'commit'` completion trigger.** A commit character accepts the row,
|
|
509
|
+
writes the character and closes the list; whatever is typed next is an ordinary
|
|
510
|
+
`'auto'` trigger. `CompletionTrigger` is `'auto' | 'explicit'`.
|
|
511
|
+
|
|
512
|
+
## License
|
|
513
|
+
|
|
514
|
+
MIT
|