@citisen/litearea 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/README.zh.md +362 -0
- package/dist/grammars.cjs +1228 -0
- package/dist/grammars.cjs.map +1 -0
- package/dist/grammars.js +1213 -0
- package/dist/grammars.js.map +1 -0
- package/dist/index.cjs +3103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +3040 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +3032 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +3010 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.cjs +453 -0
- package/dist/styles.cjs.map +1 -0
- package/dist/styles.css +432 -0
- package/dist/styles.js +447 -0
- package/dist/styles.js.map +1 -0
- package/dist/types/core/complete.d.ts +70 -0
- package/dist/types/core/complete.d.ts.map +1 -0
- package/dist/types/core/format.d.ts +34 -0
- package/dist/types/core/format.d.ts.map +1 -0
- package/dist/types/core/grammar.d.ts +14 -0
- package/dist/types/core/grammar.d.ts.map +1 -0
- package/dist/types/core/hover.d.ts +23 -0
- package/dist/types/core/hover.d.ts.map +1 -0
- package/dist/types/core/index.d.ts +12 -0
- package/dist/types/core/index.d.ts.map +1 -0
- package/dist/types/core/inspect.d.ts +34 -0
- package/dist/types/core/inspect.d.ts.map +1 -0
- package/dist/types/core/rank.d.ts +82 -0
- package/dist/types/core/rank.d.ts.map +1 -0
- package/dist/types/core/scan.d.ts +51 -0
- package/dist/types/core/scan.d.ts.map +1 -0
- package/dist/types/core/segments.d.ts +44 -0
- package/dist/types/core/segments.d.ts.map +1 -0
- package/dist/types/core/text.d.ts +113 -0
- package/dist/types/core/text.d.ts.map +1 -0
- package/dist/types/core/types.d.ts +604 -0
- package/dist/types/core/types.d.ts.map +1 -0
- package/dist/types/core/vocabulary.d.ts +82 -0
- package/dist/types/core/vocabulary.d.ts.map +1 -0
- package/dist/types/dom/create.d.ts +17 -0
- package/dist/types/dom/create.d.ts.map +1 -0
- package/dist/types/dom/editing.d.ts +89 -0
- package/dist/types/dom/editing.d.ts.map +1 -0
- package/dist/types/dom/editor.d.ts +366 -0
- package/dist/types/dom/editor.d.ts.map +1 -0
- package/dist/types/dom/index.d.ts +9 -0
- package/dist/types/dom/index.d.ts.map +1 -0
- package/dist/types/dom/mirror.d.ts +107 -0
- package/dist/types/dom/mirror.d.ts.map +1 -0
- package/dist/types/dom/overlay.d.ts +52 -0
- package/dist/types/dom/overlay.d.ts.map +1 -0
- package/dist/types/dom/popup.d.ts +95 -0
- package/dist/types/dom/popup.d.ts.map +1 -0
- package/dist/types/dom/support.d.ts +41 -0
- package/dist/types/dom/support.d.ts.map +1 -0
- package/dist/types/dom/tooltip.d.ts +39 -0
- package/dist/types/dom/tooltip.d.ts.map +1 -0
- package/dist/types/grammars/dshFont.d.ts +127 -0
- package/dist/types/grammars/dshFont.d.ts.map +1 -0
- package/dist/types/grammars/dshSentry.d.ts +84 -0
- package/dist/types/grammars/dshSentry.d.ts.map +1 -0
- package/dist/types/grammars/index.d.ts +3 -0
- package/dist/types/grammars/index.d.ts.map +1 -0
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/react/index.d.ts +91 -0
- package/dist/types/react/index.d.ts.map +1 -0
- package/dist/types/styles.d.ts +29 -0
- package/dist/types/styles.d.ts.map +1 -0
- package/docs/architecture.md +316 -0
- package/docs/completion.md +320 -0
- package/docs/grammar.md +823 -0
- package/package.json +105 -0
- package/scripts/browser-check.mjs +838 -0
- package/scripts/build-css.mjs +35 -0
- package/scripts/release.mjs +91 -0
- package/scripts/verify-package.mjs +253 -0
- package/src/core/complete.ts +286 -0
- package/src/core/format.ts +71 -0
- package/src/core/grammar.ts +40 -0
- package/src/core/hover.ts +129 -0
- package/src/core/index.ts +98 -0
- package/src/core/inspect.ts +198 -0
- package/src/core/rank.ts +317 -0
- package/src/core/scan.ts +720 -0
- package/src/core/segments.ts +185 -0
- package/src/core/text.ts +238 -0
- package/src/core/types.ts +681 -0
- package/src/core/vocabulary.ts +196 -0
- package/src/dom/create.ts +31 -0
- package/src/dom/editing.ts +213 -0
- package/src/dom/editor.ts +1143 -0
- package/src/dom/index.ts +46 -0
- package/src/dom/mirror.ts +305 -0
- package/src/dom/overlay.ts +106 -0
- package/src/dom/popup.ts +323 -0
- package/src/dom/support.ts +88 -0
- package/src/dom/tooltip.ts +112 -0
- package/src/grammars/dshFont.ts +1004 -0
- package/src/grammars/dshSentry.ts +742 -0
- package/src/grammars/index.ts +57 -0
- package/src/index.ts +122 -0
- package/src/react/index.tsx +248 -0
- package/src/styles.ts +529 -0
package/src/dom/index.ts
ADDED
|
@@ -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
|
+
}
|