@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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/README.zh.md +362 -0
  4. package/dist/grammars.cjs +1228 -0
  5. package/dist/grammars.cjs.map +1 -0
  6. package/dist/grammars.js +1213 -0
  7. package/dist/grammars.js.map +1 -0
  8. package/dist/index.cjs +3103 -0
  9. package/dist/index.cjs.map +1 -0
  10. package/dist/index.js +3040 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/react.cjs +3032 -0
  13. package/dist/react.cjs.map +1 -0
  14. package/dist/react.js +3010 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/styles.cjs +453 -0
  17. package/dist/styles.cjs.map +1 -0
  18. package/dist/styles.css +432 -0
  19. package/dist/styles.js +447 -0
  20. package/dist/styles.js.map +1 -0
  21. package/dist/types/core/complete.d.ts +70 -0
  22. package/dist/types/core/complete.d.ts.map +1 -0
  23. package/dist/types/core/format.d.ts +34 -0
  24. package/dist/types/core/format.d.ts.map +1 -0
  25. package/dist/types/core/grammar.d.ts +14 -0
  26. package/dist/types/core/grammar.d.ts.map +1 -0
  27. package/dist/types/core/hover.d.ts +23 -0
  28. package/dist/types/core/hover.d.ts.map +1 -0
  29. package/dist/types/core/index.d.ts +12 -0
  30. package/dist/types/core/index.d.ts.map +1 -0
  31. package/dist/types/core/inspect.d.ts +34 -0
  32. package/dist/types/core/inspect.d.ts.map +1 -0
  33. package/dist/types/core/rank.d.ts +82 -0
  34. package/dist/types/core/rank.d.ts.map +1 -0
  35. package/dist/types/core/scan.d.ts +51 -0
  36. package/dist/types/core/scan.d.ts.map +1 -0
  37. package/dist/types/core/segments.d.ts +44 -0
  38. package/dist/types/core/segments.d.ts.map +1 -0
  39. package/dist/types/core/text.d.ts +113 -0
  40. package/dist/types/core/text.d.ts.map +1 -0
  41. package/dist/types/core/types.d.ts +604 -0
  42. package/dist/types/core/types.d.ts.map +1 -0
  43. package/dist/types/core/vocabulary.d.ts +82 -0
  44. package/dist/types/core/vocabulary.d.ts.map +1 -0
  45. package/dist/types/dom/create.d.ts +17 -0
  46. package/dist/types/dom/create.d.ts.map +1 -0
  47. package/dist/types/dom/editing.d.ts +89 -0
  48. package/dist/types/dom/editing.d.ts.map +1 -0
  49. package/dist/types/dom/editor.d.ts +366 -0
  50. package/dist/types/dom/editor.d.ts.map +1 -0
  51. package/dist/types/dom/index.d.ts +9 -0
  52. package/dist/types/dom/index.d.ts.map +1 -0
  53. package/dist/types/dom/mirror.d.ts +107 -0
  54. package/dist/types/dom/mirror.d.ts.map +1 -0
  55. package/dist/types/dom/overlay.d.ts +52 -0
  56. package/dist/types/dom/overlay.d.ts.map +1 -0
  57. package/dist/types/dom/popup.d.ts +95 -0
  58. package/dist/types/dom/popup.d.ts.map +1 -0
  59. package/dist/types/dom/support.d.ts +41 -0
  60. package/dist/types/dom/support.d.ts.map +1 -0
  61. package/dist/types/dom/tooltip.d.ts +39 -0
  62. package/dist/types/dom/tooltip.d.ts.map +1 -0
  63. package/dist/types/grammars/dshFont.d.ts +127 -0
  64. package/dist/types/grammars/dshFont.d.ts.map +1 -0
  65. package/dist/types/grammars/dshSentry.d.ts +84 -0
  66. package/dist/types/grammars/dshSentry.d.ts.map +1 -0
  67. package/dist/types/grammars/index.d.ts +3 -0
  68. package/dist/types/grammars/index.d.ts.map +1 -0
  69. package/dist/types/index.d.ts +15 -0
  70. package/dist/types/index.d.ts.map +1 -0
  71. package/dist/types/react/index.d.ts +91 -0
  72. package/dist/types/react/index.d.ts.map +1 -0
  73. package/dist/types/styles.d.ts +29 -0
  74. package/dist/types/styles.d.ts.map +1 -0
  75. package/docs/architecture.md +316 -0
  76. package/docs/completion.md +320 -0
  77. package/docs/grammar.md +823 -0
  78. package/package.json +105 -0
  79. package/scripts/browser-check.mjs +838 -0
  80. package/scripts/build-css.mjs +35 -0
  81. package/scripts/release.mjs +91 -0
  82. package/scripts/verify-package.mjs +253 -0
  83. package/src/core/complete.ts +286 -0
  84. package/src/core/format.ts +71 -0
  85. package/src/core/grammar.ts +40 -0
  86. package/src/core/hover.ts +129 -0
  87. package/src/core/index.ts +98 -0
  88. package/src/core/inspect.ts +198 -0
  89. package/src/core/rank.ts +317 -0
  90. package/src/core/scan.ts +720 -0
  91. package/src/core/segments.ts +185 -0
  92. package/src/core/text.ts +238 -0
  93. package/src/core/types.ts +681 -0
  94. package/src/core/vocabulary.ts +196 -0
  95. package/src/dom/create.ts +31 -0
  96. package/src/dom/editing.ts +213 -0
  97. package/src/dom/editor.ts +1143 -0
  98. package/src/dom/index.ts +46 -0
  99. package/src/dom/mirror.ts +305 -0
  100. package/src/dom/overlay.ts +106 -0
  101. package/src/dom/popup.ts +323 -0
  102. package/src/dom/support.ts +88 -0
  103. package/src/dom/tooltip.ts +112 -0
  104. package/src/grammars/dshFont.ts +1004 -0
  105. package/src/grammars/dshSentry.ts +742 -0
  106. package/src/grammars/index.ts +57 -0
  107. package/src/index.ts +122 -0
  108. package/src/react/index.tsx +248 -0
  109. 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