@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,1143 @@
|
|
|
1
|
+
// ─── editor: the element, and the interaction ───────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// This is where the four things the library promises are actually kept, and each
|
|
4
|
+
// is a decision made here rather than a behaviour that emerged.
|
|
5
|
+
//
|
|
6
|
+
// **Undo survives, because the DOM owns the text.** The textarea is never given a
|
|
7
|
+
// `value` after it is created. Every edit the user makes is the browser's own, and
|
|
8
|
+
// every edit the editor makes on the user's behalf goes through the browser's
|
|
9
|
+
// editing pipeline (`src/dom/editing.ts`). The highlight layer reads the text; it
|
|
10
|
+
// never writes it. A framework binding on top must leave the element alone for the
|
|
11
|
+
// same reason, which is why the React wrapper renders a bare div and hands the
|
|
12
|
+
// element to this class.
|
|
13
|
+
//
|
|
14
|
+
// **The caret does not move, because nothing rewrites the text under it.** The
|
|
15
|
+
// editors this replaces re-rendered the value on every keystroke and then tried to
|
|
16
|
+
// put the caret back — a race the browser wins eventually, and the same mistake
|
|
17
|
+
// that destroyed the undo stack. Here there is no re-render to lose a race with,
|
|
18
|
+
// and the only caret writes are the ones a completion asked for.
|
|
19
|
+
//
|
|
20
|
+
// **The list behaves, because its range is frozen when it opens.** Filtering runs
|
|
21
|
+
// against the recorded range instead of re-deriving one from a document that has
|
|
22
|
+
// changed since, so the target cannot slide out from under the list while the user
|
|
23
|
+
// types. Enter and Tab accept, Escape dismisses, Ctrl+Space opens on demand, and
|
|
24
|
+
// the arrows move the selection without wrapping — the behaviour every editor has,
|
|
25
|
+
// which is the whole point.
|
|
26
|
+
//
|
|
27
|
+
// **The box fits, because an offscreen mirror is measured instead of the field.**
|
|
28
|
+
// Asking a textarea for its `scrollHeight` means collapsing it first, which
|
|
29
|
+
// reflows the page and flickers on every keystroke. `src/dom/mirror.ts` keeps a
|
|
30
|
+
// second element with the same typography and asks that instead.
|
|
31
|
+
|
|
32
|
+
import type {
|
|
33
|
+
Completion,
|
|
34
|
+
Decoration,
|
|
35
|
+
Diagnostic,
|
|
36
|
+
Grammar,
|
|
37
|
+
HoverInfo,
|
|
38
|
+
Range,
|
|
39
|
+
Severity,
|
|
40
|
+
SuggestionItem,
|
|
41
|
+
} from '../core/types.js'
|
|
42
|
+
import type { Inspection } from '../core/inspect.js'
|
|
43
|
+
import type { SegmentInput } from '../core/segments.js'
|
|
44
|
+
import type { CompletionTrigger } from '../core/types.js'
|
|
45
|
+
import { applyCompletion, complete } from '../core/complete.js'
|
|
46
|
+
import { resolveHover } from '../core/hover.js'
|
|
47
|
+
import { inspect } from '../core/inspect.js'
|
|
48
|
+
import { resolveGrammar, isResolvedGrammar, type ResolvedGrammar } from '../core/scan.js'
|
|
49
|
+
import { clamp } from '../core/text.js'
|
|
50
|
+
import { decorationClass, injectStyles, scopeClass, severityClass } from '../styles.js'
|
|
51
|
+
import {
|
|
52
|
+
readSelection,
|
|
53
|
+
redoField,
|
|
54
|
+
replaceThroughPipeline,
|
|
55
|
+
undoField,
|
|
56
|
+
writeDocument,
|
|
57
|
+
writeSelection,
|
|
58
|
+
type EditOutcome,
|
|
59
|
+
type TextSelection,
|
|
60
|
+
} from './editing.js'
|
|
61
|
+
import { TextMirror } from './mirror.js'
|
|
62
|
+
import { Overlay } from './overlay.js'
|
|
63
|
+
import { Popup } from './popup.js'
|
|
64
|
+
import { Tooltip } from './tooltip.js'
|
|
65
|
+
import { withDefaults } from './support.js'
|
|
66
|
+
|
|
67
|
+
/** How the box follows its content. */
|
|
68
|
+
export interface LiteAreaSizing {
|
|
69
|
+
/**
|
|
70
|
+
* Whether the height follows the content, so that no scrollbar is shown while
|
|
71
|
+
* the content fits. Default `true`.
|
|
72
|
+
*/
|
|
73
|
+
autoGrow?: boolean
|
|
74
|
+
/**
|
|
75
|
+
* The fewest lines to show, written to the textarea's own `rows` attribute.
|
|
76
|
+
*
|
|
77
|
+
* The native attribute is set rather than only a pixel minimum, so the box has
|
|
78
|
+
* the right height on the very first frame — before any measurement, and in a
|
|
79
|
+
* document where the script never runs at all. Default 1.
|
|
80
|
+
*/
|
|
81
|
+
minRows?: number
|
|
82
|
+
/** The most lines to show before a scrollbar appears. */
|
|
83
|
+
maxRows?: number
|
|
84
|
+
/** A minimum height in pixels, in addition to `minRows`. */
|
|
85
|
+
minHeight?: number
|
|
86
|
+
/** A maximum height in pixels, in addition to `maxRows`. */
|
|
87
|
+
maxHeight?: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** How the completion list behaves. */
|
|
91
|
+
export interface LiteAreaCompletion {
|
|
92
|
+
/** Whether suggestions appear while typing. Default `true`. */
|
|
93
|
+
auto?: boolean
|
|
94
|
+
/**
|
|
95
|
+
* Characters that open the list in addition to word characters.
|
|
96
|
+
*
|
|
97
|
+
* Default `' '`, and that default is doing real work: both reference languages
|
|
98
|
+
* put a value after a name, so a space is exactly where the next word becomes
|
|
99
|
+
* guessable.
|
|
100
|
+
*/
|
|
101
|
+
triggerCharacters?: string
|
|
102
|
+
/** The most rows to offer. Default 100. */
|
|
103
|
+
limit?: number
|
|
104
|
+
/** Whether the documentation panel is shown. Default `true`. */
|
|
105
|
+
showDocumentation?: boolean
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** How the hover tooltip behaves. */
|
|
109
|
+
export interface LiteAreaHover {
|
|
110
|
+
/** Whether tooltips appear at all. Default `true`. */
|
|
111
|
+
enabled?: boolean
|
|
112
|
+
/** How long the pointer must rest, in milliseconds. Default 140. */
|
|
113
|
+
delay?: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Everything a host may configure. */
|
|
117
|
+
export interface LiteAreaOptions<State = unknown> {
|
|
118
|
+
/** The language. Nothing else in this library knows anything about syntax. */
|
|
119
|
+
grammar: Grammar<State> | ResolvedGrammar<State>
|
|
120
|
+
/** The initial text. */
|
|
121
|
+
value?: string
|
|
122
|
+
/** Shown while the box is empty. */
|
|
123
|
+
placeholder?: string
|
|
124
|
+
/** Whether the user may edit. */
|
|
125
|
+
readOnly?: boolean
|
|
126
|
+
/** Whether the browser may spell-check. Default `false`, because a DSL is not prose. */
|
|
127
|
+
spellCheck?: boolean
|
|
128
|
+
/** The accessible name. */
|
|
129
|
+
ariaLabel?: string
|
|
130
|
+
/** An extra class on the wrapper. */
|
|
131
|
+
className?: string
|
|
132
|
+
/**
|
|
133
|
+
* CSS custom properties for THIS instance, for per-instance theming.
|
|
134
|
+
*
|
|
135
|
+
* A key may be written `font`, `--litearea-font`, or any other `--…` name; the first two
|
|
136
|
+
* forms mean the same thing, and a key that already starts with `--` is used verbatim so a
|
|
137
|
+
* host's own properties work too. The value is used as written, so `'13px'`,
|
|
138
|
+
* `'var(--my-ui-font)'`, and `'1.6'` are all fine.
|
|
139
|
+
*
|
|
140
|
+
* createEditor(target, {
|
|
141
|
+
* grammar,
|
|
142
|
+
* variables: { font: '"Fira Code", monospace', 'font-size': '14px', accent: '#c2410c' },
|
|
143
|
+
* })
|
|
144
|
+
*
|
|
145
|
+
* CSS stays the theme language on purpose — the editor's whole appearance is already
|
|
146
|
+
* described by custom properties, and a second vocabulary for the same facts would mean two
|
|
147
|
+
* places to keep in step. This exists only so that ONE instance can be themed without a host
|
|
148
|
+
* writing a stylesheet rule for it, and so that two editors on a page can differ.
|
|
149
|
+
*/
|
|
150
|
+
variables?: Record<string, string>
|
|
151
|
+
/** How the box follows its content. */
|
|
152
|
+
sizing?: LiteAreaSizing
|
|
153
|
+
/** How the list behaves, or `false` to switch completions off entirely. */
|
|
154
|
+
completion?: LiteAreaCompletion | false
|
|
155
|
+
/** How tooltips behave, or `false` to switch them off entirely. */
|
|
156
|
+
hover?: LiteAreaHover | false
|
|
157
|
+
/** Whether semantic decorations are painted. Default `true`. */
|
|
158
|
+
decorations?: boolean
|
|
159
|
+
/** Whether to inject the stylesheet. Default `true`. */
|
|
160
|
+
injectStyles?: boolean
|
|
161
|
+
/** A CSP nonce for the injected stylesheet. */
|
|
162
|
+
styleNonce?: string
|
|
163
|
+
/** Called after a user edit, with the new text. Not called for programmatic writes. */
|
|
164
|
+
onChange?: (value: string) => void
|
|
165
|
+
/** Called when the caret or selection moves. */
|
|
166
|
+
onSelectionChange?: (selection: TextSelection) => void
|
|
167
|
+
/** Called when the problem list changes. */
|
|
168
|
+
onDiagnostics?: (diagnostics: readonly Diagnostic[]) => void
|
|
169
|
+
/** Called when the list opens, filters, or closes. */
|
|
170
|
+
onCompletion?: (completion: Completion | undefined) => void
|
|
171
|
+
/** Called when a tooltip appears or goes away. */
|
|
172
|
+
onHover?: (info: HoverInfo | undefined) => void
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The sizing with its defaults filled in. */
|
|
176
|
+
type ResolvedSizing = Required<Pick<LiteAreaSizing, 'autoGrow' | 'minRows'>> &
|
|
177
|
+
Pick<LiteAreaSizing, 'maxRows' | 'minHeight' | 'maxHeight'>
|
|
178
|
+
|
|
179
|
+
/** The completion settings with their defaults filled in. */
|
|
180
|
+
type ResolvedCompletion = Required<LiteAreaCompletion>
|
|
181
|
+
|
|
182
|
+
/** The hover settings with their defaults filled in. */
|
|
183
|
+
type ResolvedHover = Required<LiteAreaHover>
|
|
184
|
+
|
|
185
|
+
/** How many rows a Page key moves through. */
|
|
186
|
+
const PAGE_STEP = 8
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A code editor over a textarea.
|
|
190
|
+
*
|
|
191
|
+
* The tree it builds:
|
|
192
|
+
*
|
|
193
|
+
* div.litearea ← the positioning container, and this.element
|
|
194
|
+
* div.litearea-box ← the border, the radius, and the focus ring
|
|
195
|
+
* div.litearea-layer ← the painted text, behind and inert
|
|
196
|
+
* textarea ← the real field: transparent text, visible caret
|
|
197
|
+
* div.litearea-popup ← the completion list
|
|
198
|
+
* div.litearea-tooltip ← the hover tooltip
|
|
199
|
+
*
|
|
200
|
+
* The layer is inside the box while the floating elements are outside it, because
|
|
201
|
+
* the box clips nothing and a list that had to fit inside a rounded border would be
|
|
202
|
+
* cut off at the bottom.
|
|
203
|
+
*/
|
|
204
|
+
export class LiteArea<State = unknown> {
|
|
205
|
+
/** The positioning container. Put this in the page. */
|
|
206
|
+
readonly element: HTMLDivElement
|
|
207
|
+
/** The real field. Exposed for a host that needs the element itself. */
|
|
208
|
+
readonly input: HTMLTextAreaElement
|
|
209
|
+
|
|
210
|
+
private readonly document: Document
|
|
211
|
+
private readonly view: Window | undefined
|
|
212
|
+
/**
|
|
213
|
+
* The grammar as the host declared it, kept so {@link refresh} can re-resolve it.
|
|
214
|
+
*
|
|
215
|
+
* Mutable on purpose, and it is what lets a host pass a live object — a proxy
|
|
216
|
+
* reading the newest props, say — without the editor having to be rebuilt when the
|
|
217
|
+
* language changes. A rebuild would throw away the undo history, which is the one
|
|
218
|
+
* thing this library must not do, so re-resolving is the only acceptable answer.
|
|
219
|
+
*/
|
|
220
|
+
private declaredGrammar: Grammar<State>
|
|
221
|
+
/** The grammar the engine is currently running, with its defaults filled in. */
|
|
222
|
+
private grammar: ResolvedGrammar<State>
|
|
223
|
+
private readonly box: HTMLDivElement
|
|
224
|
+
private readonly overlay: Overlay
|
|
225
|
+
private readonly popup: Popup
|
|
226
|
+
private readonly tooltip: Tooltip
|
|
227
|
+
private readonly mirror: TextMirror
|
|
228
|
+
private readonly sizing: ResolvedSizing
|
|
229
|
+
private readonly completion: ResolvedCompletion | undefined
|
|
230
|
+
private readonly hover: ResolvedHover | undefined
|
|
231
|
+
private readonly paintDecorations: boolean
|
|
232
|
+
private readonly handlers: LiteAreaOptions<State>
|
|
233
|
+
private readonly injectedStyle: HTMLStyleElement | undefined
|
|
234
|
+
private readonly instanceId: string
|
|
235
|
+
private readonly fontsReady: Promise<unknown> | undefined
|
|
236
|
+
|
|
237
|
+
/** The current inspection, and the text it was computed from. */
|
|
238
|
+
private current: Inspection<State> | undefined
|
|
239
|
+
private currentText: string | undefined
|
|
240
|
+
/** Bumped whenever the inspection changes, so the painter can skip work. */
|
|
241
|
+
private revision = 0
|
|
242
|
+
/** The list on screen, with the range frozen when it opened. */
|
|
243
|
+
private completionState: Completion | undefined
|
|
244
|
+
/** True during an IME composition, when no completion may run. */
|
|
245
|
+
private composing = false
|
|
246
|
+
/** True while the editor itself is writing, so its own edit does not re-open the list. */
|
|
247
|
+
private applying = false
|
|
248
|
+
/** The height and overflow last written, so unchanged values are not rewritten. */
|
|
249
|
+
private appliedHeight = -1
|
|
250
|
+
private appliedOverflow = ''
|
|
251
|
+
private appliedScrollbar = -1
|
|
252
|
+
/** The offset the tooltip last described, so a resting pointer does not re-query. */
|
|
253
|
+
private hoverOffset: number | undefined
|
|
254
|
+
/** The custom properties this instance set, so one that disappears can be removed. */
|
|
255
|
+
private appliedVariables = new Set<string>()
|
|
256
|
+
private hoverTimer: number | undefined
|
|
257
|
+
/** Watches for a width change, which invalidates wrapping and the box height. */
|
|
258
|
+
private resizeObserver: ResizeObserver | undefined
|
|
259
|
+
private observedWidth = -1
|
|
260
|
+
/**
|
|
261
|
+
* A signature of the last announced problem list, so the host is told once.
|
|
262
|
+
*
|
|
263
|
+
* Starts as `undefined` rather than as the empty string, because "no problems" is a
|
|
264
|
+
* real signature and a host waiting to be told that the list is clear would otherwise
|
|
265
|
+
* never hear it — it would keep whatever it was showing before the editor existed.
|
|
266
|
+
*/
|
|
267
|
+
private announced: string | undefined
|
|
268
|
+
private destroyed = false
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @param options - the grammar, the initial text, and the behaviour to use.
|
|
272
|
+
*/
|
|
273
|
+
constructor(options: LiteAreaOptions<State>) {
|
|
274
|
+
const probe = typeof document === 'undefined' ? undefined : document
|
|
275
|
+
if (probe === undefined) {
|
|
276
|
+
throw new Error('litearea: an editor needs a document, and there is none in this environment')
|
|
277
|
+
}
|
|
278
|
+
this.document = probe
|
|
279
|
+
this.view = probe.defaultView ?? undefined
|
|
280
|
+
this.handlers = options
|
|
281
|
+
// A caller may hand over either the grammar as written or one already resolved;
|
|
282
|
+
// both are accepted so a hot path can skip the work.
|
|
283
|
+
const declared = options.grammar
|
|
284
|
+
this.declaredGrammar = isResolvedGrammar(declared) ? declared.grammar : declared
|
|
285
|
+
this.grammar = resolveGrammar(this.declaredGrammar)
|
|
286
|
+
this.instanceId = `litearea-${Math.random().toString(36).slice(2, 9)}`
|
|
287
|
+
|
|
288
|
+
this.sizing = withDefaults<ResolvedSizing>(
|
|
289
|
+
{ autoGrow: true, minRows: 1 },
|
|
290
|
+
options.sizing as Partial<ResolvedSizing> | undefined,
|
|
291
|
+
)
|
|
292
|
+
this.completion =
|
|
293
|
+
options.completion === false
|
|
294
|
+
? undefined
|
|
295
|
+
: withDefaults<ResolvedCompletion>(
|
|
296
|
+
{ auto: true, triggerCharacters: ' ', limit: 100, showDocumentation: true },
|
|
297
|
+
options.completion,
|
|
298
|
+
)
|
|
299
|
+
this.hover =
|
|
300
|
+
options.hover === false
|
|
301
|
+
? undefined
|
|
302
|
+
: withDefaults<ResolvedHover>({ enabled: true, delay: 140 }, options.hover)
|
|
303
|
+
this.paintDecorations = options.decorations !== false
|
|
304
|
+
|
|
305
|
+
if (options.injectStyles !== false) {
|
|
306
|
+
this.injectedStyle = injectStyles(this.document, options.styleNonce)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── the element tree ───────────────────────────────────────────────────
|
|
310
|
+
this.element = this.document.createElement('div')
|
|
311
|
+
this.element.className =
|
|
312
|
+
options.className === undefined ? 'litearea' : `litearea ${options.className}`
|
|
313
|
+
this.element.classList.add(this.sizing.autoGrow ? 'litearea-growable' : 'litearea-resizable')
|
|
314
|
+
if (options.readOnly === true) this.element.classList.add('litearea-readonly')
|
|
315
|
+
if (options.variables !== undefined) this.applyVariables(options.variables)
|
|
316
|
+
|
|
317
|
+
this.box = this.document.createElement('div')
|
|
318
|
+
this.box.className = 'litearea-box'
|
|
319
|
+
this.element.appendChild(this.box)
|
|
320
|
+
|
|
321
|
+
this.overlay = new Overlay(this.document, {
|
|
322
|
+
scope: scopeClass,
|
|
323
|
+
decoration: decorationClass,
|
|
324
|
+
severity: severityClass,
|
|
325
|
+
})
|
|
326
|
+
this.box.appendChild(this.overlay.element)
|
|
327
|
+
|
|
328
|
+
this.input = this.document.createElement('textarea')
|
|
329
|
+
this.input.className = 'litearea-input'
|
|
330
|
+
this.input.dataset.liteareaPart = 'input'
|
|
331
|
+
this.input.spellcheck = options.spellCheck === true
|
|
332
|
+
this.input.autocomplete = 'off'
|
|
333
|
+
this.input.setAttribute('autocorrect', 'off')
|
|
334
|
+
this.input.setAttribute('autocapitalize', 'off')
|
|
335
|
+
this.input.setAttribute('wrap', 'soft')
|
|
336
|
+
this.input.readOnly = options.readOnly === true
|
|
337
|
+
// The native attribute, so the first frame is already the right height.
|
|
338
|
+
this.input.rows = this.sizing.minRows
|
|
339
|
+
if (options.placeholder !== undefined) this.input.placeholder = options.placeholder
|
|
340
|
+
if (options.ariaLabel !== undefined) this.input.setAttribute('aria-label', options.ariaLabel)
|
|
341
|
+
this.input.setAttribute('aria-autocomplete', this.completion === undefined ? 'none' : 'list')
|
|
342
|
+
this.input.setAttribute('aria-expanded', 'false')
|
|
343
|
+
this.input.setAttribute('role', 'combobox')
|
|
344
|
+
// Written exactly once, before any listener exists, so it is the element's own
|
|
345
|
+
// initial content rather than a programmatic overwrite of it.
|
|
346
|
+
this.input.value = options.value ?? ''
|
|
347
|
+
this.box.appendChild(this.input)
|
|
348
|
+
|
|
349
|
+
this.popup = new Popup(
|
|
350
|
+
this.document,
|
|
351
|
+
{
|
|
352
|
+
accept: (index) => {
|
|
353
|
+
this.acceptCompletion(index)
|
|
354
|
+
},
|
|
355
|
+
hover: (index) => {
|
|
356
|
+
this.setActive(index)
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
this.instanceId,
|
|
360
|
+
)
|
|
361
|
+
this.element.appendChild(this.popup.element)
|
|
362
|
+
this.input.setAttribute('aria-controls', this.popup.listId)
|
|
363
|
+
|
|
364
|
+
this.tooltip = new Tooltip(this.document)
|
|
365
|
+
this.element.appendChild(this.tooltip.element)
|
|
366
|
+
|
|
367
|
+
this.mirror = new TextMirror(this.document)
|
|
368
|
+
this.mirror.mount(this.document.body ?? this.document.documentElement)
|
|
369
|
+
|
|
370
|
+
// A width change invalidates both measurements: text wraps differently, and so
|
|
371
|
+
// does the paint. This is also what performs the FIRST useful measurement, since
|
|
372
|
+
// the constructor runs before the caller has mounted anything and an unmounted
|
|
373
|
+
// field has no width to measure against.
|
|
374
|
+
const Observer = (this.view as (Window & { ResizeObserver?: typeof ResizeObserver }) | undefined)
|
|
375
|
+
?.ResizeObserver
|
|
376
|
+
if (typeof Observer === 'function') {
|
|
377
|
+
this.resizeObserver = new Observer(() => {
|
|
378
|
+
const width = this.element.clientWidth
|
|
379
|
+
if (width === this.observedWidth) return
|
|
380
|
+
this.observedWidth = width
|
|
381
|
+
this.mirror.adopt(this.input)
|
|
382
|
+
this.resize(this.input.value)
|
|
383
|
+
this.placePopup()
|
|
384
|
+
})
|
|
385
|
+
this.resizeObserver.observe(this.element)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const fonts = (this.document as Document & { fonts?: { ready?: Promise<unknown> } }).fonts
|
|
389
|
+
this.fontsReady = fonts?.ready
|
|
390
|
+
|
|
391
|
+
this.bind()
|
|
392
|
+
this.sync()
|
|
393
|
+
// A web font arriving after the first paint changes every metric the mirror
|
|
394
|
+
// copied, so the box is re-measured once it lands. Without this the editor is a
|
|
395
|
+
// few pixels off until something else makes it re-layout, which is the classic
|
|
396
|
+
// "it looks wrong until you click it" bug.
|
|
397
|
+
if (this.fontsReady !== undefined) {
|
|
398
|
+
void this.fontsReady.then(() => {
|
|
399
|
+
if (!this.destroyed) this.refresh()
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── what a host reads ──────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
/** The current text. */
|
|
407
|
+
get value(): string {
|
|
408
|
+
return this.input.value
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The caret or selection. */
|
|
412
|
+
get selection(): TextSelection {
|
|
413
|
+
return readSelection(this.input)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The last inspection, or undefined before the first one. */
|
|
417
|
+
get inspection(): Inspection<State> | undefined {
|
|
418
|
+
return this.current
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The problems the grammar found. */
|
|
422
|
+
get diagnostics(): readonly Diagnostic[] {
|
|
423
|
+
return this.current?.diagnostics ?? []
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The list on screen, when one is. */
|
|
427
|
+
get currentCompletion(): Completion | undefined {
|
|
428
|
+
return this.completionState
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Whether the editor has focus. */
|
|
432
|
+
get focused(): boolean {
|
|
433
|
+
return this.document.activeElement === this.input
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── what a host calls ─────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Replace the text.
|
|
440
|
+
*
|
|
441
|
+
* `preserveHistory` writes through the editing pipeline, so the replacement is
|
|
442
|
+
* one undoable edit and Ctrl+Z brings the old text back — what a Reset button
|
|
443
|
+
* wants. Without it the value property is assigned, which is faster and clears
|
|
444
|
+
* the history, which is what loading a different document wants.
|
|
445
|
+
*
|
|
446
|
+
* This is the only path that writes the text, and it is never used for an edit
|
|
447
|
+
* the user could have made.
|
|
448
|
+
* @param next - the new text.
|
|
449
|
+
* @param preserveHistory - whether Ctrl+Z should be able to undo it.
|
|
450
|
+
* @returns how the write landed.
|
|
451
|
+
*/
|
|
452
|
+
setValue(next: string, preserveHistory = false): EditOutcome {
|
|
453
|
+
if (this.input.value === next) return 'unchanged'
|
|
454
|
+
// Marked as the editor's own write, because a history-preserving write fires an
|
|
455
|
+
// `input` event and everything downstream listens for one. Without this the caller is
|
|
456
|
+
// handed its own change back through `onChange`, and a host that stores what it is
|
|
457
|
+
// told would write its own value twice.
|
|
458
|
+
this.applying = true
|
|
459
|
+
let outcome: EditOutcome
|
|
460
|
+
try {
|
|
461
|
+
outcome = writeDocument(this.input, next, preserveHistory)
|
|
462
|
+
} finally {
|
|
463
|
+
this.applying = false
|
|
464
|
+
}
|
|
465
|
+
this.sync()
|
|
466
|
+
return outcome
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Put the caret somewhere.
|
|
471
|
+
* @param start - the anchor offset.
|
|
472
|
+
* @param end - the moving offset; defaults to `start`.
|
|
473
|
+
*/
|
|
474
|
+
setSelection(start: number, end: number = start): void {
|
|
475
|
+
writeSelection(this.input, start, end)
|
|
476
|
+
this.updateCompletionForCaret()
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Move the caret into the field. */
|
|
480
|
+
focus(): void {
|
|
481
|
+
this.input.focus()
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Undo, through the browser's history. */
|
|
485
|
+
undo(): boolean {
|
|
486
|
+
const done = undoField(this.input)
|
|
487
|
+
if (done) this.sync()
|
|
488
|
+
return done
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Redo, through the browser's history. */
|
|
492
|
+
redo(): boolean {
|
|
493
|
+
const done = redoField(this.input)
|
|
494
|
+
if (done) this.sync()
|
|
495
|
+
return done
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Set CSS custom properties on the wrapper, replacing whatever this method set last time.
|
|
500
|
+
*
|
|
501
|
+
* A property that has disappeared from the record is removed rather than left behind, so a
|
|
502
|
+
* host can un-theme by passing a smaller object. Properties set by other means — a stylesheet,
|
|
503
|
+
* or the wrapper's inline style directly — are not touched, and a removed one falls back to
|
|
504
|
+
* whatever CSS says.
|
|
505
|
+
*
|
|
506
|
+
* Safe to call at any time after construction. The constructor uses the private write-only
|
|
507
|
+
* half, because re-measuring needs the field, the mirror, and the overlay, none of which exist
|
|
508
|
+
* until it has finished.
|
|
509
|
+
* @param next - the properties, keyed as {@link LiteAreaOptions.variables} describes.
|
|
510
|
+
*/
|
|
511
|
+
setVariables(next: Readonly<Record<string, string>>): void {
|
|
512
|
+
this.applyVariables(next)
|
|
513
|
+
// A font or a line height changes every measurement, so the box is re-measured rather than
|
|
514
|
+
// waiting for the next keystroke to notice.
|
|
515
|
+
this.mirror.adopt(this.input)
|
|
516
|
+
this.appliedHeight = -1
|
|
517
|
+
this.sync()
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Write the properties, and forget the ones that are gone. No re-measure. */
|
|
521
|
+
private applyVariables(next: Readonly<Record<string, string>>): void {
|
|
522
|
+
const keep = new Set<string>()
|
|
523
|
+
for (const [key, value] of Object.entries(next)) {
|
|
524
|
+
const name = variableName(key)
|
|
525
|
+
keep.add(name)
|
|
526
|
+
this.element.style.setProperty(name, value)
|
|
527
|
+
}
|
|
528
|
+
for (const name of this.appliedVariables) {
|
|
529
|
+
if (!keep.has(name)) this.element.style.removeProperty(name)
|
|
530
|
+
}
|
|
531
|
+
this.appliedVariables = keep
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Re-read the document with the same text.
|
|
536
|
+
*
|
|
537
|
+
* For a host whose grammar depends on something outside it — an installed font
|
|
538
|
+
* list that has just been re-read, a palette that changed — and for the moment a
|
|
539
|
+
* web font finishes loading.
|
|
540
|
+
*
|
|
541
|
+
* The grammar is re-resolved here, which is what makes a live grammar object
|
|
542
|
+
* work: a host that rebuilds its rules on every render can call `refresh()` and
|
|
543
|
+
* the editor picks the new ones up without being rebuilt, so the undo history
|
|
544
|
+
* survives a language change.
|
|
545
|
+
*/
|
|
546
|
+
refresh(): void {
|
|
547
|
+
this.grammar = resolveGrammar(this.declaredGrammar)
|
|
548
|
+
this.current = undefined
|
|
549
|
+
this.currentText = undefined
|
|
550
|
+
this.mirror.adopt(this.input)
|
|
551
|
+
this.overlay.invalidate()
|
|
552
|
+
this.appliedHeight = -1
|
|
553
|
+
this.appliedOverflow = ''
|
|
554
|
+
this.appliedScrollbar = -1
|
|
555
|
+
this.sync()
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Open the completion list on demand, as Ctrl+Space does. */
|
|
559
|
+
showCompletions(): void {
|
|
560
|
+
if (this.completion === undefined) return
|
|
561
|
+
this.openCompletion('explicit')
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Close the completion list. */
|
|
565
|
+
hideCompletions(): void {
|
|
566
|
+
this.closeCompletion()
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Remove the editor and every listener it owns. */
|
|
570
|
+
destroy(): void {
|
|
571
|
+
if (this.destroyed) return
|
|
572
|
+
this.destroyed = true
|
|
573
|
+
this.unbind()
|
|
574
|
+
this.resizeObserver?.disconnect()
|
|
575
|
+
this.resizeObserver = undefined
|
|
576
|
+
if (this.hoverTimer !== undefined) this.view?.clearTimeout(this.hoverTimer)
|
|
577
|
+
this.popup.destroy()
|
|
578
|
+
this.tooltip.destroy()
|
|
579
|
+
this.overlay.destroy()
|
|
580
|
+
this.mirror.destroy()
|
|
581
|
+
// The injected sheet is shared by every editor on the page, so it stays:
|
|
582
|
+
// removing it would unstyle the others.
|
|
583
|
+
void this.injectedStyle
|
|
584
|
+
this.element.remove()
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── the pipeline ──────────────────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Bring everything up to date with the field.
|
|
591
|
+
*
|
|
592
|
+
* The inspection is cached on the text, so moving the caret or scrolling costs
|
|
593
|
+
* nothing beyond the paint — and the paint itself is skipped when neither the
|
|
594
|
+
* text nor the analysis changed.
|
|
595
|
+
*/
|
|
596
|
+
private sync(): void {
|
|
597
|
+
const text = this.input.value
|
|
598
|
+
if (this.current === undefined || this.currentText !== text) {
|
|
599
|
+
this.current = inspect(text, this.grammar)
|
|
600
|
+
this.currentText = text
|
|
601
|
+
this.revision += 1
|
|
602
|
+
}
|
|
603
|
+
const inspection = this.current
|
|
604
|
+
this.paint(inspection)
|
|
605
|
+
this.resize(text)
|
|
606
|
+
this.overlay.syncScroll(this.input)
|
|
607
|
+
this.announceDiagnostics(inspection)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Paint the layer, and mark the box when a problem is an error. */
|
|
611
|
+
private paint(inspection: Inspection<State>): void {
|
|
612
|
+
const painting: SegmentInput = {
|
|
613
|
+
tokens: inspection.tokens,
|
|
614
|
+
decorations: this.paintDecorations ? inspection.decorations : [],
|
|
615
|
+
diagnostics: inspection.diagnostics,
|
|
616
|
+
}
|
|
617
|
+
const key = [
|
|
618
|
+
String(this.revision),
|
|
619
|
+
this.paintDecorations ? 'd' : '-',
|
|
620
|
+
String(painting.decorations.length),
|
|
621
|
+
String(painting.diagnostics.length),
|
|
622
|
+
].join(':')
|
|
623
|
+
this.overlay.render(inspection.text, painting, key, this.grammar.fallbackScope)
|
|
624
|
+
const hasError = inspection.diagnostics.some((diagnostic) => diagnostic.severity === 'error')
|
|
625
|
+
this.element.classList.toggle('litearea-invalid', hasError)
|
|
626
|
+
this.input.setAttribute('aria-invalid', hasError ? 'true' : 'false')
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Tell the host, once, when the problem list actually changed. */
|
|
630
|
+
private announceDiagnostics(inspection: Inspection<State>): void {
|
|
631
|
+
if (this.handlers.onDiagnostics === undefined) return
|
|
632
|
+
const key = inspection.diagnostics
|
|
633
|
+
.map(
|
|
634
|
+
(diagnostic) =>
|
|
635
|
+
`${String(diagnostic.from)}:${diagnostic.code ?? ''}:${diagnostic.message}`,
|
|
636
|
+
)
|
|
637
|
+
.join('|')
|
|
638
|
+
if (key === this.announced) return
|
|
639
|
+
this.announced = key
|
|
640
|
+
this.handlers.onDiagnostics(inspection.diagnostics)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Size the box to its content.
|
|
645
|
+
*
|
|
646
|
+
* The mirror has no scrollbar, so its measurement is the height the content wants
|
|
647
|
+
* with the full width available — which is exactly the number that decides whether
|
|
648
|
+
* a scrollbar is needed at all. If it fits under the maximum, the height becomes
|
|
649
|
+
* the content height and the overflow stays hidden, which is the promise of "grow
|
|
650
|
+
* and shrink so no scrollbar is ever shown". If it does not fit, the height is
|
|
651
|
+
* clamped and the overflow becomes `auto`.
|
|
652
|
+
*
|
|
653
|
+
* Clamping introduces a second problem that is easy to miss: a scrollbar narrows
|
|
654
|
+
* the text, so the field rewraps, so the PAINT no longer wraps the same way and
|
|
655
|
+
* every coloured span slides off its character. The measured scrollbar width is
|
|
656
|
+
* therefore published as a custom property, and the stylesheet adds it to the
|
|
657
|
+
* layer's own right padding so the two keep wrapping identically.
|
|
658
|
+
*
|
|
659
|
+
* An UNMOUNTED field is skipped rather than measured. Before the element is in the
|
|
660
|
+
* document it has no layout, so its `clientWidth` is zero, the mirror wraps at every
|
|
661
|
+
* character, and the measurement comes back several times too tall — a wrong height
|
|
662
|
+
* that then has to be corrected on the next keystroke, which is what a box that
|
|
663
|
+
* jumps on first focus actually is. The `ResizeObserver` installed at construction
|
|
664
|
+
* does the first real measurement as soon as there is a width to measure against.
|
|
665
|
+
* @param text - the current text.
|
|
666
|
+
*/
|
|
667
|
+
private resize(text: string): void {
|
|
668
|
+
if (!this.sizing.autoGrow) {
|
|
669
|
+
// Still adopted, because caret geometry depends on the mirror being shaped like
|
|
670
|
+
// the field even when the field's height is the host's business.
|
|
671
|
+
this.mirror.adopt(this.input)
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
if (!this.input.isConnected || this.input.clientWidth === 0) return
|
|
675
|
+
const chrome = this.mirror.verticalPadding()
|
|
676
|
+
const lineHeight = this.mirror.lineHeight(this.input)
|
|
677
|
+
const minPx = Math.max(
|
|
678
|
+
this.sizing.minHeight ?? 0,
|
|
679
|
+
lineHeight * (this.sizing.minRows ?? 1) + chrome,
|
|
680
|
+
)
|
|
681
|
+
const maxCandidate = Math.min(
|
|
682
|
+
this.sizing.maxHeight ?? Number.POSITIVE_INFINITY,
|
|
683
|
+
this.sizing.maxRows === undefined
|
|
684
|
+
? Number.POSITIVE_INFINITY
|
|
685
|
+
: lineHeight * this.sizing.maxRows + chrome,
|
|
686
|
+
)
|
|
687
|
+
// A maximum below the minimum is a host mistake, and honouring it would make
|
|
688
|
+
// the box smaller than the host was told it could be.
|
|
689
|
+
const maxPx = Math.max(maxCandidate, minPx)
|
|
690
|
+
|
|
691
|
+
const content = this.mirror.contentHeight(this.input, text)
|
|
692
|
+
const wanted = clamp(Math.max(content, minPx), minPx, maxPx)
|
|
693
|
+
const overflow = content > wanted + 0.5 ? 'auto' : 'hidden'
|
|
694
|
+
|
|
695
|
+
if (Math.abs(wanted - this.appliedHeight) > 0.5) {
|
|
696
|
+
this.input.style.height = `${String(Math.round(wanted))}px`
|
|
697
|
+
this.appliedHeight = wanted
|
|
698
|
+
}
|
|
699
|
+
if (overflow !== this.appliedOverflow) {
|
|
700
|
+
this.input.style.overflowY = overflow
|
|
701
|
+
this.appliedOverflow = overflow
|
|
702
|
+
}
|
|
703
|
+
// Read AFTER the overflow is applied, because that is when a scrollbar exists
|
|
704
|
+
// and therefore when there is a width to report.
|
|
705
|
+
const scrollbar = overflow === 'auto' ? this.input.offsetWidth - this.input.clientWidth : 0
|
|
706
|
+
const width = Math.max(0, scrollbar)
|
|
707
|
+
if (width !== this.appliedScrollbar) {
|
|
708
|
+
this.element.style.setProperty('--litearea-scrollbar', `${String(width)}px`)
|
|
709
|
+
this.appliedScrollbar = width
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ── completion ────────────────────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
/** The caret's offset, clamped into the text. */
|
|
716
|
+
private caret(): number {
|
|
717
|
+
return clamp(readSelection(this.input).start, 0, this.input.value.length)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Resolve and show a list for the caret. */
|
|
721
|
+
private openCompletion(trigger: CompletionTrigger): void {
|
|
722
|
+
if (this.completion === undefined || this.current === undefined) return
|
|
723
|
+
// The source that answered last time is passed back so the same one answers again
|
|
724
|
+
// while it stays eligible. The RANGE is deliberately not held: it is recomputed from
|
|
725
|
+
// the caret on every call, so it grows with the word being typed.
|
|
726
|
+
const result = complete(this.current, this.grammar, {
|
|
727
|
+
text: this.input.value,
|
|
728
|
+
caret: this.caret(),
|
|
729
|
+
trigger,
|
|
730
|
+
previousSourceId: this.completionState?.sourceId,
|
|
731
|
+
limit: this.completion.limit,
|
|
732
|
+
})
|
|
733
|
+
if (result === undefined || result.rows.length === 0) {
|
|
734
|
+
this.closeCompletion()
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
this.completionState = result
|
|
738
|
+
this.popup.show(result.rows, 0, this.completion.showDocumentation)
|
|
739
|
+
this.input.setAttribute('aria-expanded', 'true')
|
|
740
|
+
this.syncActiveDescendant()
|
|
741
|
+
this.placePopup()
|
|
742
|
+
this.handlers.onCompletion?.(result)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Close the list and tell the host. */
|
|
746
|
+
private closeCompletion(): void {
|
|
747
|
+
if (this.completionState === undefined && !this.popup.isOpen) return
|
|
748
|
+
this.completionState = undefined
|
|
749
|
+
this.popup.close()
|
|
750
|
+
this.input.setAttribute('aria-expanded', 'false')
|
|
751
|
+
this.input.removeAttribute('aria-activedescendant')
|
|
752
|
+
this.handlers.onCompletion?.(undefined)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Keep the field's `aria-activedescendant` pointing at the active row. */
|
|
756
|
+
private syncActiveDescendant(): void {
|
|
757
|
+
const id = this.popup.activeRowId
|
|
758
|
+
if (id === undefined) this.input.removeAttribute('aria-activedescendant')
|
|
759
|
+
else this.input.setAttribute('aria-activedescendant', id)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Put the list under the caret. */
|
|
763
|
+
private placePopup(): void {
|
|
764
|
+
if (this.completionState === undefined) return
|
|
765
|
+
const box = this.mirror.caretBox(this.input, this.caret())
|
|
766
|
+
if (box === undefined) return
|
|
767
|
+
// The mirror reports relative to the field's border box; the list is positioned
|
|
768
|
+
// against the wrapper. Measuring both rects is used rather than `offsetLeft`
|
|
769
|
+
// arithmetic, because `offsetParent` changes as soon as a host wraps the editor
|
|
770
|
+
// in something positioned, and the caret must not notice.
|
|
771
|
+
const inputRect = this.input.getBoundingClientRect()
|
|
772
|
+
const elementRect = this.element.getBoundingClientRect()
|
|
773
|
+
this.popup.place(
|
|
774
|
+
{
|
|
775
|
+
x: inputRect.left - elementRect.left + box.x,
|
|
776
|
+
y: inputRect.top - elementRect.top + box.y,
|
|
777
|
+
height: box.height,
|
|
778
|
+
},
|
|
779
|
+
this.element,
|
|
780
|
+
{ width: this.view?.innerWidth ?? 0, height: this.view?.innerHeight ?? 0 },
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Take the active row.
|
|
786
|
+
* @param index - the row to take.
|
|
787
|
+
* @param commitCharacter - a character typed to trigger the pick, written after
|
|
788
|
+
* the completion so the keystroke is not swallowed.
|
|
789
|
+
*/
|
|
790
|
+
private acceptCompletion(index: number, commitCharacter?: string): void {
|
|
791
|
+
const state = this.completionState
|
|
792
|
+
const row: SuggestionItem | undefined = this.popup.items[index]?.item
|
|
793
|
+
if (state === undefined || row === undefined) return
|
|
794
|
+
const applied = applyCompletion(this.input.value, state.range, row)
|
|
795
|
+
this.applying = true
|
|
796
|
+
try {
|
|
797
|
+
// Only the range that changed is written, and it goes through the editing
|
|
798
|
+
// pipeline, so Ctrl+Z undoes the completion as one edit and the rest of the
|
|
799
|
+
// document — and its history — is untouched.
|
|
800
|
+
replaceThroughPipeline(this.input, applied.from, applied.to, applied.insert)
|
|
801
|
+
writeSelection(this.input, applied.caret)
|
|
802
|
+
if (commitCharacter !== undefined) {
|
|
803
|
+
replaceThroughPipeline(this.input, applied.caret, applied.caret, commitCharacter)
|
|
804
|
+
writeSelection(this.input, applied.caret + commitCharacter.length)
|
|
805
|
+
}
|
|
806
|
+
} finally {
|
|
807
|
+
this.applying = false
|
|
808
|
+
}
|
|
809
|
+
this.closeCompletion()
|
|
810
|
+
this.sync()
|
|
811
|
+
const inspection = this.current
|
|
812
|
+
if (inspection !== undefined) {
|
|
813
|
+
this.grammar.grammar.onAccept?.({
|
|
814
|
+
text: this.input.value,
|
|
815
|
+
caret: this.caret(),
|
|
816
|
+
item: row,
|
|
817
|
+
state: inspection.state,
|
|
818
|
+
})
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Decide what an edit does to the list. */
|
|
823
|
+
private updateCompletionAfterInput(data: string, deletion: boolean): void {
|
|
824
|
+
if (this.completion === undefined || this.applying || this.composing) return
|
|
825
|
+
if (this.input.readOnly) return
|
|
826
|
+
if (deletion) {
|
|
827
|
+
// Deleting inside an open list re-filters it; deleting with no list open opens
|
|
828
|
+
// nothing, because a backspace is not a request for suggestions.
|
|
829
|
+
if (this.completionState !== undefined) this.openCompletion('auto')
|
|
830
|
+
return
|
|
831
|
+
}
|
|
832
|
+
if (this.completionState !== undefined) {
|
|
833
|
+
this.openCompletion('auto')
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
if (!this.completion.auto) return
|
|
837
|
+
if (!this.shouldAutoOpen(data)) return
|
|
838
|
+
this.openCompletion('auto')
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Whether a keystroke is a reason to offer suggestions. */
|
|
842
|
+
private shouldAutoOpen(data: string): boolean {
|
|
843
|
+
if (data === '' || this.completion === undefined) return false
|
|
844
|
+
const last = data.slice(-1)
|
|
845
|
+
if (this.completion.triggerCharacters.includes(last)) return true
|
|
846
|
+
return this.grammar.wordChars.test(last)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Close a list the caret has left. */
|
|
850
|
+
private updateCompletionForCaret(): void {
|
|
851
|
+
const state = this.completionState
|
|
852
|
+
if (state === undefined) return
|
|
853
|
+
const caret = this.caret()
|
|
854
|
+
if (caret < state.range.from || caret > state.range.to) this.closeCompletion()
|
|
855
|
+
else this.placePopup()
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── hover ─────────────────────────────────────────────────────────────────
|
|
859
|
+
|
|
860
|
+
/** Start, or restart, the timer that shows a tooltip. */
|
|
861
|
+
private queueHover(offset: number, event: MouseEvent): void {
|
|
862
|
+
if (this.hover === undefined || !this.hover.enabled || this.current === undefined) return
|
|
863
|
+
if (offset === this.hoverOffset && this.tooltip.isOpen) return
|
|
864
|
+
this.hoverOffset = offset
|
|
865
|
+
this.hideTooltip()
|
|
866
|
+
const clientX = event.clientX
|
|
867
|
+
const clientY = event.clientY
|
|
868
|
+
this.hoverTimer = this.view?.setTimeout(() => {
|
|
869
|
+
this.hoverTimer = undefined
|
|
870
|
+
this.showHover(offset, clientX, clientY)
|
|
871
|
+
}, this.hover.delay)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** Resolve and show a tooltip. */
|
|
875
|
+
private showHover(offset: number, clientX: number, clientY: number): void {
|
|
876
|
+
if (this.current === undefined || this.destroyed) return
|
|
877
|
+
const info = resolveHover(this.current, this.grammar, offset)
|
|
878
|
+
if (info === undefined) {
|
|
879
|
+
this.hideTooltip()
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
const rect = this.element.getBoundingClientRect()
|
|
883
|
+
const lineHeight = this.mirror.lineHeight(this.input)
|
|
884
|
+
this.tooltip.show(
|
|
885
|
+
info,
|
|
886
|
+
{ x: clientX - rect.left, y: clientY - rect.top, height: lineHeight },
|
|
887
|
+
this.element,
|
|
888
|
+
{ width: this.view?.innerWidth ?? 0, height: this.view?.innerHeight ?? 0 },
|
|
889
|
+
)
|
|
890
|
+
this.handlers.onHover?.(info)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Hide the tooltip and forget what it described. */
|
|
894
|
+
private hideTooltip(): void {
|
|
895
|
+
if (this.hoverTimer !== undefined) {
|
|
896
|
+
this.view?.clearTimeout(this.hoverTimer)
|
|
897
|
+
this.hoverTimer = undefined
|
|
898
|
+
}
|
|
899
|
+
if (!this.tooltip.isOpen) return
|
|
900
|
+
this.tooltip.hide()
|
|
901
|
+
this.handlers.onHover?.(undefined)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── events ────────────────────────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
/** Attach every listener. */
|
|
907
|
+
private bind(): void {
|
|
908
|
+
this.input.addEventListener('input', this.onInput)
|
|
909
|
+
this.input.addEventListener('keydown', this.onKeyDown)
|
|
910
|
+
this.input.addEventListener('scroll', this.onScroll)
|
|
911
|
+
this.input.addEventListener('click', this.onCaretMoved)
|
|
912
|
+
this.input.addEventListener('keyup', this.onKeyUp)
|
|
913
|
+
this.input.addEventListener('select', this.onCaretMoved)
|
|
914
|
+
this.input.addEventListener('blur', this.onBlur)
|
|
915
|
+
this.input.addEventListener('mousemove', this.onMouseMove)
|
|
916
|
+
this.input.addEventListener('mouseleave', this.onMouseLeave)
|
|
917
|
+
this.input.addEventListener('compositionstart', this.onCompositionStart)
|
|
918
|
+
this.input.addEventListener('compositionend', this.onCompositionEnd)
|
|
919
|
+
this.document.addEventListener('selectionchange', this.onSelectionChange)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Detach every listener. */
|
|
923
|
+
private unbind(): void {
|
|
924
|
+
this.input.removeEventListener('input', this.onInput)
|
|
925
|
+
this.input.removeEventListener('keydown', this.onKeyDown)
|
|
926
|
+
this.input.removeEventListener('scroll', this.onScroll)
|
|
927
|
+
this.input.removeEventListener('click', this.onCaretMoved)
|
|
928
|
+
this.input.removeEventListener('keyup', this.onKeyUp)
|
|
929
|
+
this.input.removeEventListener('select', this.onCaretMoved)
|
|
930
|
+
this.input.removeEventListener('blur', this.onBlur)
|
|
931
|
+
this.input.removeEventListener('mousemove', this.onMouseMove)
|
|
932
|
+
this.input.removeEventListener('mouseleave', this.onMouseLeave)
|
|
933
|
+
this.input.removeEventListener('compositionstart', this.onCompositionStart)
|
|
934
|
+
this.input.removeEventListener('compositionend', this.onCompositionEnd)
|
|
935
|
+
this.document.removeEventListener('selectionchange', this.onSelectionChange)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
private readonly onInput = (event: Event): void => {
|
|
939
|
+
this.sync()
|
|
940
|
+
const input = event as InputEvent
|
|
941
|
+
const type = input.inputType ?? ''
|
|
942
|
+
const deletion = type.startsWith('delete')
|
|
943
|
+
const data = typeof input.data === 'string' ? input.data : ''
|
|
944
|
+
// Typing is not a request for a tooltip, and leaving one up over text that has
|
|
945
|
+
// moved is worse than not having shown it.
|
|
946
|
+
this.hideTooltip()
|
|
947
|
+
this.updateCompletionAfterInput(data, deletion)
|
|
948
|
+
if (!this.applying) this.handlers.onChange?.(this.input.value)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
|
952
|
+
// First, because a keystroke a row asked for is a pick and not text.
|
|
953
|
+
if (this.commitCharacter(event)) return
|
|
954
|
+
|
|
955
|
+
if (event.key === 'Escape') {
|
|
956
|
+
if (this.popup.isOpen) {
|
|
957
|
+
event.preventDefault()
|
|
958
|
+
this.closeCompletion()
|
|
959
|
+
return
|
|
960
|
+
}
|
|
961
|
+
this.hideTooltip()
|
|
962
|
+
return
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (event.key === ' ' && (event.ctrlKey || event.metaKey)) {
|
|
966
|
+
event.preventDefault()
|
|
967
|
+
if (this.popup.isOpen) this.closeCompletion()
|
|
968
|
+
else this.openCompletion('explicit')
|
|
969
|
+
return
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (!this.popup.isOpen) return
|
|
973
|
+
const last = this.popup.items.length - 1
|
|
974
|
+
|
|
975
|
+
switch (event.key) {
|
|
976
|
+
case 'ArrowDown':
|
|
977
|
+
event.preventDefault()
|
|
978
|
+
// No wrapping, because a list that jumps from the end back to the start on
|
|
979
|
+
// one arrow press is a list nobody can navigate deliberately.
|
|
980
|
+
this.setActive(Math.min(this.popup.activeIndex + 1, last))
|
|
981
|
+
return
|
|
982
|
+
case 'ArrowUp':
|
|
983
|
+
event.preventDefault()
|
|
984
|
+
this.setActive(Math.max(this.popup.activeIndex - 1, 0))
|
|
985
|
+
return
|
|
986
|
+
case 'PageDown':
|
|
987
|
+
event.preventDefault()
|
|
988
|
+
this.setActive(Math.min(this.popup.activeIndex + PAGE_STEP, last))
|
|
989
|
+
return
|
|
990
|
+
case 'PageUp':
|
|
991
|
+
event.preventDefault()
|
|
992
|
+
this.setActive(Math.max(this.popup.activeIndex - PAGE_STEP, 0))
|
|
993
|
+
return
|
|
994
|
+
case 'Enter':
|
|
995
|
+
event.preventDefault()
|
|
996
|
+
this.acceptCompletion(this.popup.activeIndex)
|
|
997
|
+
return
|
|
998
|
+
case 'Tab':
|
|
999
|
+
event.preventDefault()
|
|
1000
|
+
this.acceptCompletion(this.popup.activeIndex)
|
|
1001
|
+
return
|
|
1002
|
+
default:
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Whether a keystroke is a commit character for the active row.
|
|
1009
|
+
*
|
|
1010
|
+
* Typing `=` at the end of `shape` takes the `shape=` row and keeps the
|
|
1011
|
+
* character, rather than either swallowing the keystroke or leaving the row to be
|
|
1012
|
+
* clicked. A row opts in; nothing has a commit character by default.
|
|
1013
|
+
* @param event - the key event.
|
|
1014
|
+
* @returns whether the keystroke was consumed.
|
|
1015
|
+
*/
|
|
1016
|
+
private commitCharacter(event: KeyboardEvent): boolean {
|
|
1017
|
+
if (!this.popup.isOpen) return false
|
|
1018
|
+
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return false
|
|
1019
|
+
const characters = this.popup.activeRow?.item.commitCharacters
|
|
1020
|
+
if (characters === undefined || !characters.includes(event.key)) return false
|
|
1021
|
+
event.preventDefault()
|
|
1022
|
+
this.acceptCompletion(this.popup.activeIndex, event.key)
|
|
1023
|
+
return true
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Make a row active and keep the ARIA pointer in step. */
|
|
1027
|
+
private setActive(index: number): void {
|
|
1028
|
+
this.popup.setActive(index)
|
|
1029
|
+
this.syncActiveDescendant()
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private readonly onScroll = (): void => {
|
|
1033
|
+
this.overlay.syncScroll(this.input)
|
|
1034
|
+
this.placePopup()
|
|
1035
|
+
this.hideTooltip()
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private readonly onCaretMoved = (): void => {
|
|
1039
|
+
this.updateCompletionForCaret()
|
|
1040
|
+
this.handlers.onSelectionChange?.(readSelection(this.input))
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
private readonly onKeyUp = (event: KeyboardEvent): void => {
|
|
1044
|
+
// Shift+Arrow and Home/End move the caret without an `input` event, and a list
|
|
1045
|
+
// left open over a caret that has walked out of its range would complete the
|
|
1046
|
+
// wrong thing.
|
|
1047
|
+
if (event.key.startsWith('Arrow') || event.key === 'Home' || event.key === 'End') {
|
|
1048
|
+
this.updateCompletionForCaret()
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
private readonly onSelectionChange = (): void => {
|
|
1053
|
+
if (this.document.activeElement !== this.input) return
|
|
1054
|
+
this.handlers.onSelectionChange?.(readSelection(this.input))
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
private readonly onBlur = (): void => {
|
|
1058
|
+
// A pick beats this, because the list cancels the default on `mousedown`; a
|
|
1059
|
+
// genuine blur closes everything.
|
|
1060
|
+
this.closeCompletion()
|
|
1061
|
+
this.hideTooltip()
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
private readonly onMouseMove = (event: MouseEvent): void => {
|
|
1065
|
+
if (this.hover === undefined || !this.hover.enabled) return
|
|
1066
|
+
if (this.popup.isOpen) {
|
|
1067
|
+
this.hideTooltip()
|
|
1068
|
+
return
|
|
1069
|
+
}
|
|
1070
|
+
// The BROWSER is asked where the pointer is in the text, rather than the editor
|
|
1071
|
+
// measuring wrapped proportional glyphs by hand — which is exactly the geometry
|
|
1072
|
+
// a hand-written measurement always gets slightly wrong.
|
|
1073
|
+
const offset = caretOffsetFromPoint(this.document, event.clientX, event.clientY)
|
|
1074
|
+
if (offset === undefined) return
|
|
1075
|
+
this.queueHover(offset, event)
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private readonly onMouseLeave = (): void => {
|
|
1079
|
+
this.hoverOffset = undefined
|
|
1080
|
+
this.hideTooltip()
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private readonly onCompositionStart = (): void => {
|
|
1084
|
+
this.composing = true
|
|
1085
|
+
this.closeCompletion()
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
private readonly onCompositionEnd = (): void => {
|
|
1089
|
+
this.composing = false
|
|
1090
|
+
this.sync()
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* The full custom-property name for a key a host may have written in a few ways.
|
|
1096
|
+
*
|
|
1097
|
+
* `font`, `litearea-font`, and `--litearea-font` all mean the same property; a key that already
|
|
1098
|
+
* begins with `--` is used verbatim, so `--litearea-font` and a host's own `--my-brand` both
|
|
1099
|
+
* work without the editor having to guess which prefix was meant.
|
|
1100
|
+
* @param key - the key as written.
|
|
1101
|
+
* @returns the custom-property name.
|
|
1102
|
+
*/
|
|
1103
|
+
function variableName(key: string): string {
|
|
1104
|
+
if (key.startsWith('--')) return key
|
|
1105
|
+
return key.startsWith('litearea-') ? `--${key}` : `--litearea-${key}`
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* The caret offset a viewport point falls on.
|
|
1110
|
+
*
|
|
1111
|
+
* A free function so the editor does not reach into the document for it, and so the
|
|
1112
|
+
* two spellings of the API live in one place.
|
|
1113
|
+
* @param ownerDocument - the document.
|
|
1114
|
+
* @param x - viewport x.
|
|
1115
|
+
* @param y - viewport y.
|
|
1116
|
+
* @returns the offset, or undefined when the browser cannot say.
|
|
1117
|
+
*/
|
|
1118
|
+
function caretOffsetFromPoint(
|
|
1119
|
+
ownerDocument: Document,
|
|
1120
|
+
x: number,
|
|
1121
|
+
y: number,
|
|
1122
|
+
): number | undefined {
|
|
1123
|
+
const probe = ownerDocument as Document & {
|
|
1124
|
+
caretPositionFromPoint?: (x: number, y: number) => { offset: number } | null
|
|
1125
|
+
caretRangeFromPoint?: (x: number, y: number) => Range | null
|
|
1126
|
+
}
|
|
1127
|
+
if (typeof probe.caretPositionFromPoint === 'function') {
|
|
1128
|
+
return probe.caretPositionFromPoint(x, y)?.offset
|
|
1129
|
+
}
|
|
1130
|
+
if (typeof probe.caretRangeFromPoint === 'function') {
|
|
1131
|
+
return probe.caretRangeFromPoint(x, y)?.startOffset
|
|
1132
|
+
}
|
|
1133
|
+
return undefined
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** A severity, re-exported so a host can narrow a diagnostic without importing core. */
|
|
1137
|
+
export type EditorSeverity = Severity
|
|
1138
|
+
|
|
1139
|
+
/** A range, re-exported for the same reason. */
|
|
1140
|
+
export type EditorRange = Range
|
|
1141
|
+
|
|
1142
|
+
/** A decoration, re-exported for the same reason. */
|
|
1143
|
+
export type EditorDecoration = Decoration
|