@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,46 @@
1
+ // ─── the DOM layer's public surface ────────────────────────────────────────
2
+ //
3
+ // The engine is available without any of this, and this is available without any
4
+ // framework. Everything here needs a document.
5
+
6
+ export {
7
+ LiteArea,
8
+ type EditorDecoration,
9
+ type EditorRange,
10
+ type EditorSeverity,
11
+ type LiteAreaCompletion,
12
+ type LiteAreaHover,
13
+ type LiteAreaOptions,
14
+ type LiteAreaSizing,
15
+ } from './editor.js'
16
+
17
+ export { createEditor } from './create.js'
18
+
19
+ export {
20
+ dispatchInput,
21
+ fieldLineHeight,
22
+ readSelection,
23
+ redoField,
24
+ replaceThroughPipeline,
25
+ undoField,
26
+ writeDocument,
27
+ writeSelection,
28
+ type EditOutcome,
29
+ type TextSelection,
30
+ } from './editing.js'
31
+
32
+ export { TextMirror, type CaretBox } from './mirror.js'
33
+
34
+ export { Overlay, type OverlayClassNames } from './overlay.js'
35
+
36
+ export { Popup, type AnchorBox, type PopupHandlers } from './popup.js'
37
+
38
+ export { Tooltip, type TooltipAnchor } from './tooltip.js'
39
+
40
+ export {
41
+ canEditThroughPipeline,
42
+ hasCaretHitTest,
43
+ hasDocument,
44
+ offsetFromPoint,
45
+ withDefaults,
46
+ } from './support.js'
@@ -0,0 +1,305 @@
1
+ // ─── mirror: measuring text the textarea will not tell us ───────────────────
2
+ //
3
+ // A textarea can report its `scrollHeight` and its selection offsets, and that is
4
+ // the whole of it. It cannot say where the caret is on screen, and its
5
+ // `scrollHeight` cannot be trusted while its own height is being changed — asking
6
+ // for it means first collapsing the element, which reflows the page and makes the
7
+ // box flicker on every keystroke.
8
+ //
9
+ // So a second element is kept offscreen with the field's exact typography and the
10
+ // same box, and the question is asked of IT. Two jobs come out of the same
11
+ // element: where the caret is, and how tall the content is. Both are pure
12
+ // measurements of text, both need the same copied styles, and both are wrong in
13
+ // the same way if a single property is missed — which is why there is one mirror
14
+ // and not two.
15
+ //
16
+ // `visibility: hidden` rather than `display: none`, because a hidden element still
17
+ // lays out and a removed one does not.
18
+
19
+ import { clamp } from '../core/text.js'
20
+
21
+ /**
22
+ * The properties that decide where a glyph lands.
23
+ *
24
+ * Anything in this list that the mirror does not copy makes every measurement
25
+ * wrong by a little, and the failures are not obvious: a missing
26
+ * `letter-spacing` moves the caret a fraction of a character per character, so the
27
+ * popup drifts further off the longer the line is. The list is deliberately
28
+ * exhaustive rather than "the ones that seemed to matter".
29
+ */
30
+ const COPIED_PROPERTIES = [
31
+ 'fontFamily',
32
+ 'fontSize',
33
+ 'fontWeight',
34
+ 'fontStyle',
35
+ 'fontStretch',
36
+ 'fontVariantLigatures',
37
+ 'fontKerning',
38
+ 'fontFeatureSettings',
39
+ 'lineHeight',
40
+ 'letterSpacing',
41
+ 'wordSpacing',
42
+ 'textTransform',
43
+ 'textIndent',
44
+ 'textAlign',
45
+ 'direction',
46
+ 'tabSize',
47
+ 'whiteSpace',
48
+ 'overflowWrap',
49
+ 'wordBreak',
50
+ 'hyphens',
51
+ 'paddingTop',
52
+ 'paddingRight',
53
+ 'paddingBottom',
54
+ 'paddingLeft',
55
+ 'borderTopWidth',
56
+ 'borderRightWidth',
57
+ 'borderBottomWidth',
58
+ 'borderLeftWidth',
59
+ 'borderTopStyle',
60
+ 'borderRightStyle',
61
+ 'borderBottomStyle',
62
+ 'borderLeftStyle',
63
+ 'boxSizing',
64
+ ] as const
65
+
66
+ /** Where the caret is, in pixels relative to the field's border box. */
67
+ export interface CaretBox {
68
+ /** Distance from the field's left border edge to the caret. */
69
+ x: number
70
+ /** Distance from the field's top border edge to the caret's line box. */
71
+ y: number
72
+ /** The height of the caret's line. */
73
+ height: number
74
+ /** The line height in force, whether or not it was declared. */
75
+ lineHeight: number
76
+ }
77
+
78
+ /** A zero-width space: content that draws nothing and occupies a line. */
79
+ const SENTINEL = '\u200b'
80
+
81
+ /**
82
+ * An offscreen element that lays text out exactly as a given textarea does.
83
+ *
84
+ * One instance per field is enough; the editor owns exactly one.
85
+ */
86
+ export class TextMirror {
87
+ /** The measuring element. Kept out of the document's flow by `position: fixed`. */
88
+ readonly element: HTMLDivElement
89
+ private readonly document: Document
90
+ private readonly view: Window | undefined
91
+ /** The field this mirror is currently shaped like. */
92
+ private adopted: HTMLTextAreaElement | undefined
93
+ /** The line height measured with the field, or 0 before the first measurement. */
94
+ private measuredLineHeight = 0
95
+
96
+ /**
97
+ * @param ownerDocument - the document to create the element in.
98
+ */
99
+ constructor(ownerDocument: Document) {
100
+ this.document = ownerDocument
101
+ this.view = ownerDocument.defaultView ?? undefined
102
+ this.element = ownerDocument.createElement('div')
103
+ this.element.setAttribute('aria-hidden', 'true')
104
+ this.element.dataset.liteareaPart = 'mirror'
105
+ // `white-space: pre-wrap` is the one property both jobs depend on absolutely:
106
+ // a newline has to break the line in the mirror exactly as it does in the
107
+ // field, and a run of spaces has to survive.
108
+ this.element.style.cssText = [
109
+ 'position:fixed',
110
+ 'top:0',
111
+ 'left:0',
112
+ 'visibility:hidden',
113
+ 'pointer-events:none',
114
+ 'z-index:-1',
115
+ 'margin:0',
116
+ 'overflow:hidden',
117
+ 'white-space:pre-wrap',
118
+ 'overflow-wrap:break-word',
119
+ 'word-break:break-word',
120
+ 'box-sizing:border-box',
121
+ ].join(';')
122
+ }
123
+
124
+ /** Whether the mirror is in a document. */
125
+ get mounted(): boolean {
126
+ return this.element.isConnected
127
+ }
128
+
129
+ /**
130
+ * Mount the mirror, once, so measurements have a layout to read.
131
+ * @param parent - where to mount it. The body is right unless the document has none.
132
+ */
133
+ mount(parent?: Element): void {
134
+ if (this.mounted) return
135
+ const host = parent ?? this.document.body ?? this.document.documentElement
136
+ if (host === null || host === undefined) return
137
+ host.appendChild(this.element)
138
+ }
139
+
140
+ /**
141
+ * Shape the mirror like a field.
142
+ *
143
+ * The width is the interesting part. It has to be the field's CONTENT width, or
144
+ * text wraps in one and not the other — and the field's `clientWidth` is its
145
+ * content plus padding but NOT its border, while the mirror is `border-box`. So
146
+ * the borders are added back, and what is deliberately left out is the
147
+ * scrollbar: a field clamped to its maximum height has one, and the text wraps
148
+ * inside the narrower area above it.
149
+ * @param field - the textarea to imitate.
150
+ */
151
+ adopt(field: HTMLTextAreaElement): void {
152
+ this.mount()
153
+ this.adopted = field
154
+ const view = this.view
155
+ if (view === null || view === undefined) return
156
+ const styles = view.getComputedStyle(field)
157
+ for (const property of COPIED_PROPERTIES) {
158
+ this.element.style.setProperty(
159
+ property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`),
160
+ styles.getPropertyValue(property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)),
161
+ )
162
+ }
163
+ const borderLeft = Number.parseFloat(styles.borderLeftWidth) || 0
164
+ const borderRight = Number.parseFloat(styles.borderRightWidth) || 0
165
+ this.element.style.width = `${String(field.clientWidth + borderLeft + borderRight)}px`
166
+ this.measuredLineHeight = 0
167
+ }
168
+
169
+ /**
170
+ * The line height in force, measured rather than assumed.
171
+ *
172
+ * `line-height: normal` is a real value and a common one, and it cannot be read
173
+ * as a number — `parseFloat('normal')` is `NaN`. So it is measured by laying one
174
+ * line of text out and taking the height, which is also the only way to be right
175
+ * about a font whose normal leading is not 1.2.
176
+ * @param field - the field to measure against.
177
+ * @returns the line height in pixels.
178
+ */
179
+ lineHeight(field: HTMLTextAreaElement): number {
180
+ if (this.measuredLineHeight > 0) return this.measuredLineHeight
181
+ const view = this.view
182
+ if (view === null || view === undefined) return 0
183
+ const styles = view.getComputedStyle(field)
184
+ const declared = Number.parseFloat(styles.lineHeight)
185
+ if (Number.isFinite(declared) && declared > 0) {
186
+ this.measuredLineHeight = declared
187
+ return declared
188
+ }
189
+ this.adopt(field)
190
+ this.setText('M')
191
+ const padding = this.verticalPadding()
192
+ const height = this.element.getBoundingClientRect().height - padding
193
+ // A font that reports nothing usable still needs an answer, and 1.2 × the font
194
+ // size is what `normal` means for essentially every font in practice.
195
+ const fontSize = Number.parseFloat(styles.fontSize)
196
+ const fallback = (Number.isFinite(fontSize) ? fontSize : 16) * 1.2
197
+ this.measuredLineHeight = height > 0 ? height : fallback
198
+ return this.measuredLineHeight
199
+ }
200
+
201
+ /** The mirror's vertical padding plus border, which every height includes. */
202
+ verticalPadding(): number {
203
+ const view = this.view
204
+ if (view === null || view === undefined) return 0
205
+ const styles = view.getComputedStyle(this.element)
206
+ const sum =
207
+ (Number.parseFloat(styles.paddingTop) || 0) +
208
+ (Number.parseFloat(styles.paddingBottom) || 0) +
209
+ (Number.parseFloat(styles.borderTopWidth) || 0) +
210
+ (Number.parseFloat(styles.borderBottomWidth) || 0)
211
+ return sum
212
+ }
213
+
214
+ /**
215
+ * Put text in the mirror with nothing else in it.
216
+ * @param text - the content.
217
+ */
218
+ setText(text: string): void {
219
+ this.element.textContent = text
220
+ }
221
+
222
+ /**
223
+ * The height a field needs to show a document without scrolling.
224
+ *
225
+ * The trailing-newline problem is handled here. A div with `white-space:
226
+ * pre-wrap` and content ending in `\n` does not lay out a final empty line — the
227
+ * newline breaks the line but nothing follows it — so the measured height comes
228
+ * back one line short and the field grows a scrollbar exactly when the user
229
+ * presses Enter at the end. A zero-width space after the newline gives that last
230
+ * line something to be.
231
+ * @param field - the field being sized.
232
+ * @param value - the text it holds.
233
+ * @returns the border-box height the content requires.
234
+ */
235
+ contentHeight(field: HTMLTextAreaElement, value: string): number {
236
+ this.adopt(field)
237
+ const needsSentinel = value === '' || value.endsWith('\n') || value.endsWith('\r')
238
+ this.setText(needsSentinel ? `${value}${SENTINEL}` : value)
239
+ return this.element.getBoundingClientRect().height
240
+ }
241
+
242
+ /**
243
+ * Where the caret sits, in pixels relative to the field's border box.
244
+ *
245
+ * The trick is a marker element holding the character AFTER the caret, measured
246
+ * against the mirror. Everything before the caret lays out normally, so the
247
+ * marker lands exactly where the next glyph will be — which is where the caret
248
+ * is. At the end of the document there is no next character, so a zero-width
249
+ * space stands in for it.
250
+ *
251
+ * The field's own scroll offset is subtracted, because the caret's position on
252
+ * screen is what the popup has to be placed against, and a scrolled field moves
253
+ * its text without moving its border box.
254
+ *
255
+ * @param field - the field the caret is in.
256
+ * @param offset - the caret's character offset.
257
+ * @returns the caret box, or undefined when there is no layout to measure.
258
+ */
259
+ caretBox(field: HTMLTextAreaElement, offset: number): CaretBox | undefined {
260
+ const view = this.view
261
+ if (view === null || view === undefined) return undefined
262
+ this.adopt(field)
263
+ const value = field.value
264
+ const position = clamp(offset, 0, value.length)
265
+ const before = value.slice(0, position)
266
+ const after = value.slice(position)
267
+ const next = after === '' ? SENTINEL : after.charAt(0)
268
+
269
+ // Rebuilt rather than patched: the mirror is offscreen and measuring it is
270
+ // cheap, while keeping incremental DOM in step with a fast typist is not.
271
+ this.element.textContent = ''
272
+ this.element.appendChild(this.document.createTextNode(before))
273
+ const marker = this.document.createElement('span')
274
+ marker.textContent = next
275
+ // A span that can wrap would let the marker jump to the next line on its own,
276
+ // which is exactly the wrong answer for a caret at a line end.
277
+ marker.style.whiteSpace = 'pre'
278
+ this.element.appendChild(marker)
279
+ this.element.appendChild(this.document.createTextNode(after.slice(next.length)))
280
+
281
+ const mirrorRect = this.element.getBoundingClientRect()
282
+ const markerRect = marker.getBoundingClientRect()
283
+ const lineHeight = this.lineHeight(field)
284
+ return {
285
+ x: markerRect.left - mirrorRect.left - field.scrollLeft,
286
+ y: markerRect.top - mirrorRect.top - field.scrollTop,
287
+ height: markerRect.height > 0 ? markerRect.height : lineHeight,
288
+ lineHeight,
289
+ }
290
+ }
291
+
292
+ /** Take the mirror out of the document. */
293
+ destroy(): void {
294
+ this.element.remove()
295
+ this.adopted = undefined
296
+ }
297
+
298
+ /**
299
+ * The field this mirror was last shaped like.
300
+ * @returns the field, or undefined before {@link adopt}.
301
+ */
302
+ get field(): HTMLTextAreaElement | undefined {
303
+ return this.adopted
304
+ }
305
+ }
@@ -0,0 +1,106 @@
1
+ // ─── overlay: the painted layer behind the field ────────────────────────────
2
+ //
3
+ // The layer draws the same characters as the textarea, in colour, and it is
4
+ // positioned to share the field's box exactly. It never measures anything: the
5
+ // segments come out of `buildSegments` in document order and are appended back to
6
+ // back, so the browser's own text layout puts every character where the field put
7
+ // it. The only thing this module does besides writing DOM is keep the layer
8
+ // scrolled with the field.
9
+
10
+ import type { SegmentInput } from '../core/segments.js'
11
+ import { buildSegments, segmentClasses } from '../core/segments.js'
12
+
13
+ /** How the layer turns a scope, a decoration, or a severity into a class. */
14
+ export interface OverlayClassNames {
15
+ scope: (scope: string) => string
16
+ decoration: (kind: string) => string
17
+ severity: (severity: string) => string
18
+ }
19
+
20
+ /** The painted layer and the element that holds the paint. */
21
+ export class Overlay {
22
+ /** The scroll container. Same box as the field. */
23
+ readonly element: HTMLDivElement
24
+ /** The element the spans are written into. */
25
+ private readonly paint: HTMLDivElement
26
+ private readonly document: Document
27
+ private readonly classNames: OverlayClassNames
28
+ /** What was painted last, so an unchanged document is not repainted. */
29
+ private paintedText: string | undefined
30
+ private paintedKey = ''
31
+
32
+ /**
33
+ * @param ownerDocument - the document to build in.
34
+ * @param classNames - the three class-name mappings.
35
+ */
36
+ constructor(ownerDocument: Document, classNames: OverlayClassNames) {
37
+ this.document = ownerDocument
38
+ this.classNames = classNames
39
+ this.element = ownerDocument.createElement('div')
40
+ this.element.className = 'litearea-layer'
41
+ this.element.setAttribute('aria-hidden', 'true')
42
+ this.element.dataset.liteareaPart = 'layer'
43
+ this.paint = ownerDocument.createElement('div')
44
+ this.paint.className = 'litearea-paint'
45
+ this.element.appendChild(this.paint)
46
+ }
47
+
48
+ /**
49
+ * Paint a document.
50
+ *
51
+ * `key` is whatever the caller knows changed. When it and the text both match
52
+ * the last call the work is skipped, which is what keeps a caret move or a
53
+ * mouse hover from rebuilding every span on the page.
54
+ * @param text - the document.
55
+ * @param input - the tokens, decorations, and diagnostics.
56
+ * @param key - a cheap signature of everything that affects the paint.
57
+ * @param fallbackScope - the scope for characters no token covers.
58
+ */
59
+ render(text: string, input: SegmentInput, key: string, fallbackScope = 'text'): void {
60
+ if (this.paintedText === text && this.paintedKey === key) return
61
+ const segments = buildSegments(text, input, fallbackScope)
62
+ const fragment = this.document.createDocumentFragment()
63
+ for (const segment of segments) {
64
+ const span = this.document.createElement('span')
65
+ span.className = segmentClasses(
66
+ segment,
67
+ this.classNames.scope,
68
+ this.classNames.decoration,
69
+ this.classNames.severity,
70
+ ).join(' ')
71
+ if (segment.title !== undefined) span.title = segment.title
72
+ span.textContent = segment.text
73
+ fragment.appendChild(span)
74
+ }
75
+ this.paint.replaceChildren(fragment)
76
+ this.paintedText = text
77
+ this.paintedKey = key
78
+ }
79
+
80
+ /**
81
+ * Follow the field's scroll position.
82
+ *
83
+ * The layer is `overflow: hidden` and its content is taller than its box exactly
84
+ * when the field is: an auto-grown field has nothing to scroll and a field
85
+ * clamped to its maximum height has everything to scroll. Copying the offset is
86
+ * therefore enough, and it is more reliable than a transform, which can leave
87
+ * the text on a half pixel.
88
+ * @param field - the textarea.
89
+ */
90
+ syncScroll(field: HTMLTextAreaElement): void {
91
+ if (this.element.scrollTop !== field.scrollTop) this.element.scrollTop = field.scrollTop
92
+ if (this.element.scrollLeft !== field.scrollLeft) this.element.scrollLeft = field.scrollLeft
93
+ }
94
+
95
+ /** Forget what was painted, so the next render rebuilds. */
96
+ invalidate(): void {
97
+ this.paintedText = undefined
98
+ this.paintedKey = ''
99
+ }
100
+
101
+ /** Take the layer out of the document. */
102
+ destroy(): void {
103
+ this.element.remove()
104
+ this.paint.replaceChildren()
105
+ }
106
+ }