@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/popup.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// ─── popup: the completion list ─────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Three parts, and the split between them is the whole design:
|
|
4
|
+
//
|
|
5
|
+
// div.litearea-popup the positioned container; scrolls NOTHING
|
|
6
|
+
// div.litearea-list the scroller, and the listbox role; holds rows only
|
|
7
|
+
// div.litearea-docs the documentation, pinned below the list
|
|
8
|
+
//
|
|
9
|
+
// The documentation used to be appended INSIDE the scrolling element, after the rows, and
|
|
10
|
+
// that made it unreachable in the one case it matters most: with a long list and a long
|
|
11
|
+
// explanation, the explanation sat at the end of the scroll range, so a keyboard user could
|
|
12
|
+
// never see it at all — the arrows move the active row, not the scrollbar — and a mouse user
|
|
13
|
+
// had to scroll down to read it and then back up to reach the next row. Pinning it outside the
|
|
14
|
+
// scroller fixes both, because the panel never moves when the list scrolls and never has to be
|
|
15
|
+
// scrolled to in the first place.
|
|
16
|
+
//
|
|
17
|
+
// Two interaction details are also deliberate. A pick must not blur the field: a click fires
|
|
18
|
+
// `mousedown` before `blur`, so the row listens for `mousedown`, cancels the default to keep
|
|
19
|
+
// the focus, and accepts — listening for `click` instead means the field is already blurred by
|
|
20
|
+
// the time the handler runs. And the list scrolls only as far as the active row needs, because
|
|
21
|
+
// a list that re-centres on every arrow press looks like it is jumping.
|
|
22
|
+
|
|
23
|
+
import type { CompletionRow, SuggestionItem } from '../core/types.js'
|
|
24
|
+
import { highlightSegments } from '../core/rank.js'
|
|
25
|
+
|
|
26
|
+
/** Where a floating element is allowed to sit. */
|
|
27
|
+
export interface AnchorBox {
|
|
28
|
+
/** Distance from the positioning container's left edge. */
|
|
29
|
+
x: number
|
|
30
|
+
/** Distance from the positioning container's top edge. */
|
|
31
|
+
y: number
|
|
32
|
+
/** The height of the line the anchor sits on. */
|
|
33
|
+
height: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the list tells its owner. */
|
|
37
|
+
export interface PopupHandlers {
|
|
38
|
+
/** A row was chosen with the mouse. */
|
|
39
|
+
accept(index: number): void
|
|
40
|
+
/** The pointer moved over a row, which makes it active. */
|
|
41
|
+
hover(index: number): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The completion list, with its documentation panel. */
|
|
45
|
+
export class Popup {
|
|
46
|
+
/** The positioned container. */
|
|
47
|
+
readonly element: HTMLDivElement
|
|
48
|
+
/** The scrolling element, which holds the rows and nothing else. */
|
|
49
|
+
readonly list: HTMLDivElement
|
|
50
|
+
private readonly document: Document
|
|
51
|
+
private readonly handlers: PopupHandlers
|
|
52
|
+
private rows: readonly CompletionRow[] = []
|
|
53
|
+
private active = -1
|
|
54
|
+
private open = false
|
|
55
|
+
private showDocs = true
|
|
56
|
+
private readonly docs: HTMLDivElement
|
|
57
|
+
private readonly prefix: string
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param ownerDocument - the document to build in.
|
|
61
|
+
* @param handlers - how to report a pick and a hover.
|
|
62
|
+
* @param idPrefix - a stable prefix for row ids, so two editors do not collide.
|
|
63
|
+
*/
|
|
64
|
+
constructor(ownerDocument: Document, handlers: PopupHandlers, idPrefix: string) {
|
|
65
|
+
this.document = ownerDocument
|
|
66
|
+
this.handlers = handlers
|
|
67
|
+
this.prefix = idPrefix
|
|
68
|
+
|
|
69
|
+
this.element = ownerDocument.createElement('div')
|
|
70
|
+
this.element.className = 'litearea-popup'
|
|
71
|
+
this.element.dataset.liteareaPart = 'popup'
|
|
72
|
+
this.element.dataset.open = 'false'
|
|
73
|
+
|
|
74
|
+
this.list = ownerDocument.createElement('div')
|
|
75
|
+
this.list.className = 'litearea-list'
|
|
76
|
+
this.list.setAttribute('role', 'listbox')
|
|
77
|
+
this.list.id = `${idPrefix}-listbox`
|
|
78
|
+
this.element.appendChild(this.list)
|
|
79
|
+
|
|
80
|
+
this.docs = ownerDocument.createElement('div')
|
|
81
|
+
this.docs.className = 'litearea-docs'
|
|
82
|
+
this.docs.hidden = true
|
|
83
|
+
this.element.appendChild(this.docs)
|
|
84
|
+
|
|
85
|
+
// Bound to the list rather than to each row, so replacing every row on each keystroke does
|
|
86
|
+
// not mean attaching and detaching listeners on every keystroke.
|
|
87
|
+
this.list.addEventListener('mousedown', this.onMouseDown)
|
|
88
|
+
this.list.addEventListener('mousemove', this.onMouseMove)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether the list is showing. */
|
|
92
|
+
get isOpen(): boolean {
|
|
93
|
+
return this.open
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The active row's index, or -1. */
|
|
97
|
+
get activeIndex(): number {
|
|
98
|
+
return this.active
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The rows currently shown. */
|
|
102
|
+
get items(): readonly CompletionRow[] {
|
|
103
|
+
return this.rows
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The active row, when there is one. */
|
|
107
|
+
get activeRow(): CompletionRow | undefined {
|
|
108
|
+
return this.rows[this.active]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The id the field's `aria-controls` should name. It is the list, not the container. */
|
|
112
|
+
get listId(): string {
|
|
113
|
+
return this.list.id
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The id of the active row, for the field's `aria-activedescendant`.
|
|
118
|
+
*
|
|
119
|
+
* The list does not write that attribute itself: the field belongs to the editor, and a
|
|
120
|
+
* floating list reaching out of its own subtree to find it is the kind of coupling that
|
|
121
|
+
* breaks the moment the two are mounted somewhere unexpected.
|
|
122
|
+
* @returns the row id, or undefined when no row is active.
|
|
123
|
+
*/
|
|
124
|
+
get activeRowId(): string | undefined {
|
|
125
|
+
return this.active >= 0 ? this.rowId(this.active) : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Show a list.
|
|
130
|
+
* @param rows - the rows, already ranked.
|
|
131
|
+
* @param active - the row to make active.
|
|
132
|
+
* @param showDocs - whether to render the documentation panel.
|
|
133
|
+
*/
|
|
134
|
+
show(rows: readonly CompletionRow[], active: number, showDocs: boolean): void {
|
|
135
|
+
this.rows = rows
|
|
136
|
+
this.active = active
|
|
137
|
+
this.showDocs = showDocs
|
|
138
|
+
this.open = true
|
|
139
|
+
this.element.dataset.open = 'true'
|
|
140
|
+
this.element.dataset.docs = showDocs ? 'true' : 'false'
|
|
141
|
+
this.list.scrollTop = 0
|
|
142
|
+
this.render()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Hide the list and forget its rows. */
|
|
146
|
+
close(): void {
|
|
147
|
+
if (!this.open) return
|
|
148
|
+
this.open = false
|
|
149
|
+
this.active = -1
|
|
150
|
+
this.rows = []
|
|
151
|
+
this.element.dataset.open = 'false'
|
|
152
|
+
this.list.replaceChildren()
|
|
153
|
+
this.docs.hidden = true
|
|
154
|
+
this.docs.replaceChildren()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Make a row active without rebuilding the list.
|
|
159
|
+
* @param index - the row index.
|
|
160
|
+
*/
|
|
161
|
+
setActive(index: number): void {
|
|
162
|
+
if (index === this.active) return
|
|
163
|
+
const previous = this.list.querySelector<HTMLElement>(`#${this.rowId(this.active)}`)
|
|
164
|
+
if (previous !== null) previous.setAttribute('aria-selected', 'false')
|
|
165
|
+
this.active = index
|
|
166
|
+
const next = this.list.querySelector<HTMLElement>(`#${this.rowId(index)}`)
|
|
167
|
+
if (next !== null) {
|
|
168
|
+
next.setAttribute('aria-selected', 'true')
|
|
169
|
+
// Only as far as needed, and only the LIST scrolls: the documentation panel is outside
|
|
170
|
+
// this element, so moving the active row can never push the explanation out of sight.
|
|
171
|
+
const top = next.offsetTop
|
|
172
|
+
const bottom = top + next.offsetHeight
|
|
173
|
+
if (top < this.list.scrollTop) this.list.scrollTop = top
|
|
174
|
+
else if (bottom > this.list.scrollTop + this.list.clientHeight) {
|
|
175
|
+
this.list.scrollTop = bottom - this.list.clientHeight
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
this.renderDocs()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Place the list under an anchor, flipping above it when there is no room below.
|
|
183
|
+
*
|
|
184
|
+
* The container's own box is what is measured, so the documentation panel counts towards the
|
|
185
|
+
* height and a popup whose rows and explanation together would run off the bottom flips as a
|
|
186
|
+
* whole rather than being cut in half.
|
|
187
|
+
* @param anchor - the caret's box, in the container's coordinates.
|
|
188
|
+
* @param container - the element the list is positioned against.
|
|
189
|
+
* @param viewport - the visible area to stay inside.
|
|
190
|
+
*/
|
|
191
|
+
place(anchor: AnchorBox, container: HTMLElement, viewport: { width: number; height: number }): void {
|
|
192
|
+
const box = this.element.getBoundingClientRect()
|
|
193
|
+
const containerBox = container.getBoundingClientRect()
|
|
194
|
+
const gap = 4
|
|
195
|
+
let top = anchor.y + anchor.height + gap
|
|
196
|
+
if (containerBox.top + top + box.height > viewport.height - gap) {
|
|
197
|
+
const above = anchor.y - box.height - gap
|
|
198
|
+
// Flipping only helps if there is somewhere to flip TO; otherwise the list stays below
|
|
199
|
+
// and is clamped by the max-heights in the stylesheet.
|
|
200
|
+
if (containerBox.top + above >= gap) top = above
|
|
201
|
+
}
|
|
202
|
+
let left = anchor.x
|
|
203
|
+
const maxLeft = containerBox.width - box.width
|
|
204
|
+
if (left > maxLeft) left = Math.max(0, maxLeft)
|
|
205
|
+
this.element.style.top = `${String(Math.round(top))}px`
|
|
206
|
+
this.element.style.left = `${String(Math.round(left))}px`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Take the list out of the document. */
|
|
210
|
+
destroy(): void {
|
|
211
|
+
this.list.removeEventListener('mousedown', this.onMouseDown)
|
|
212
|
+
this.list.removeEventListener('mousemove', this.onMouseMove)
|
|
213
|
+
this.element.remove()
|
|
214
|
+
this.list.replaceChildren()
|
|
215
|
+
this.docs.replaceChildren()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The DOM id of a row, which `aria-activedescendant` points at. */
|
|
219
|
+
rowId(index: number): string {
|
|
220
|
+
return `${this.prefix}-row-${String(index)}`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
private render(): void {
|
|
226
|
+
const fragment = this.document.createDocumentFragment()
|
|
227
|
+
this.rows.forEach((row, index) => {
|
|
228
|
+
const element = this.document.createElement('div')
|
|
229
|
+
element.className = 'litearea-row'
|
|
230
|
+
element.id = this.rowId(index)
|
|
231
|
+
element.setAttribute('role', 'option')
|
|
232
|
+
element.setAttribute('aria-selected', index === this.active ? 'true' : 'false')
|
|
233
|
+
element.dataset.index = String(index)
|
|
234
|
+
|
|
235
|
+
const kind = this.document.createElement('span')
|
|
236
|
+
kind.className = `litearea-rowKind litearea-kind-${row.item.kind ?? 'value'}`
|
|
237
|
+
element.appendChild(kind)
|
|
238
|
+
|
|
239
|
+
const label = this.document.createElement('span')
|
|
240
|
+
label.className = 'litearea-rowLabel'
|
|
241
|
+
// Emphasised as segments rather than as markup: a font family may legitimately be called
|
|
242
|
+
// `<b>`, and a list that built HTML from a label would either render it or have to escape
|
|
243
|
+
// it, and both are wrong for text that came from the user's machine.
|
|
244
|
+
for (const piece of highlightSegments(row.item.label, row.indices)) {
|
|
245
|
+
if (piece.matched) {
|
|
246
|
+
const mark = this.document.createElement('span')
|
|
247
|
+
mark.className = 'litearea-rowMatch'
|
|
248
|
+
mark.textContent = piece.text
|
|
249
|
+
label.appendChild(mark)
|
|
250
|
+
} else {
|
|
251
|
+
label.appendChild(this.document.createTextNode(piece.text))
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
element.appendChild(label)
|
|
255
|
+
|
|
256
|
+
if (row.item.detail !== undefined) {
|
|
257
|
+
const detail = this.document.createElement('span')
|
|
258
|
+
detail.className = 'litearea-rowDetail'
|
|
259
|
+
detail.textContent = row.item.detail
|
|
260
|
+
element.appendChild(detail)
|
|
261
|
+
}
|
|
262
|
+
fragment.appendChild(element)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// Rows go in the list and nowhere else. The documentation is a sibling that is never
|
|
266
|
+
// rebuilt here, so converting a long explanation cannot disturb the scroll position.
|
|
267
|
+
this.list.replaceChildren(fragment)
|
|
268
|
+
this.renderDocs()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Refresh the documentation panel from the active row. */
|
|
272
|
+
private renderDocs(): void {
|
|
273
|
+
const item: SuggestionItem | undefined = this.rows[this.active]?.item
|
|
274
|
+
const hasDocs =
|
|
275
|
+
this.showDocs &&
|
|
276
|
+
this.open &&
|
|
277
|
+
item !== undefined &&
|
|
278
|
+
(item.documentation !== undefined || item.detail !== undefined)
|
|
279
|
+
this.docs.hidden = !hasDocs
|
|
280
|
+
if (!hasDocs || item === undefined) {
|
|
281
|
+
this.docs.replaceChildren()
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
const fragment = this.document.createDocumentFragment()
|
|
285
|
+
const title = this.document.createElement('div')
|
|
286
|
+
title.className = 'litearea-docsTitle'
|
|
287
|
+
title.textContent = item.label
|
|
288
|
+
fragment.appendChild(title)
|
|
289
|
+
if (item.detail !== undefined) {
|
|
290
|
+
const detail = this.document.createElement('div')
|
|
291
|
+
detail.className = 'litearea-docsDetail'
|
|
292
|
+
detail.textContent = item.detail
|
|
293
|
+
fragment.appendChild(detail)
|
|
294
|
+
}
|
|
295
|
+
if (item.documentation !== undefined) {
|
|
296
|
+
const body = this.document.createElement('div')
|
|
297
|
+
body.className = 'litearea-docsBody'
|
|
298
|
+
body.textContent = item.documentation
|
|
299
|
+
fragment.appendChild(body)
|
|
300
|
+
}
|
|
301
|
+
this.docs.replaceChildren(fragment)
|
|
302
|
+
// A new explanation is read from its beginning, which matters when the previous one was
|
|
303
|
+
// long enough to have been scrolled.
|
|
304
|
+
this.docs.scrollTop = 0
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private readonly onMouseDown = (event: MouseEvent): void => {
|
|
308
|
+
const row = (event.target as HTMLElement | null)?.closest<HTMLElement>('.litearea-row')
|
|
309
|
+
if (row === null || row === undefined) return
|
|
310
|
+
// Before blur, and cancelling the default is what keeps the field focused.
|
|
311
|
+
event.preventDefault()
|
|
312
|
+
const index = Number.parseInt(row.dataset.index ?? '-1', 10)
|
|
313
|
+
if (Number.isInteger(index) && index >= 0) this.handlers.accept(index)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private readonly onMouseMove = (event: MouseEvent): void => {
|
|
317
|
+
const row = (event.target as HTMLElement | null)?.closest<HTMLElement>('.litearea-row')
|
|
318
|
+
if (row === null || row === undefined) return
|
|
319
|
+
const index = Number.parseInt(row.dataset.index ?? '-1', 10)
|
|
320
|
+
if (!Number.isInteger(index) || index < 0 || index === this.active) return
|
|
321
|
+
this.handlers.hover(index)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// ─── support: what this browser can actually do ─────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The engine is pure and needs nothing. This module is the only place that asks
|
|
4
|
+
// the environment a question, so the answer is in one place and the rest of the
|
|
5
|
+
// DOM layer can assume it.
|
|
6
|
+
|
|
7
|
+
/** Whether a document is available at all, so an import is safe under Node. */
|
|
8
|
+
export function hasDocument(): boolean {
|
|
9
|
+
return typeof document !== 'undefined' && document !== null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether the browser still offers `document.execCommand`.
|
|
14
|
+
*
|
|
15
|
+
* It is deprecated, and it is also the only way to change a textarea's value while
|
|
16
|
+
* keeping the browser's own undo stack intact — no modern input API can be driven
|
|
17
|
+
* from script, because a synthetic event is untrusted and the browser refuses to
|
|
18
|
+
* treat it as a user edit. So the check is not "is this nice API available" but
|
|
19
|
+
* "can this edit be undone", and the editor degrades honestly when the answer is
|
|
20
|
+
* no: the edit still lands, and the history it should have joined does not.
|
|
21
|
+
* @returns whether an undo-preserving edit is possible.
|
|
22
|
+
*/
|
|
23
|
+
export function canEditThroughPipeline(): boolean {
|
|
24
|
+
return hasDocument() && typeof document.execCommand === 'function'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether `caretPositionFromPoint` or its WebKit spelling exists. */
|
|
28
|
+
export function hasCaretHitTest(): boolean {
|
|
29
|
+
if (!hasDocument()) return false
|
|
30
|
+
const probe = document as Document & {
|
|
31
|
+
caretPositionFromPoint?: unknown
|
|
32
|
+
caretRangeFromPoint?: unknown
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
typeof probe.caretPositionFromPoint === 'function' ||
|
|
36
|
+
typeof probe.caretRangeFromPoint === 'function'
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The character offset a point in the viewport falls on, according to the browser.
|
|
42
|
+
*
|
|
43
|
+
* The browser is asked rather than the editor computing it, because hit-testing
|
|
44
|
+
* wrapped, proportional text is exactly the kind of geometry a hand-written
|
|
45
|
+
* measurement gets subtly wrong. A textarea's transparent text still lays out
|
|
46
|
+
* normally, so the caret APIs answer for it correctly.
|
|
47
|
+
* @param x - viewport x.
|
|
48
|
+
* @param y - viewport y.
|
|
49
|
+
* @returns the offset, or undefined when the browser cannot say.
|
|
50
|
+
*/
|
|
51
|
+
export function offsetFromPoint(x: number, y: number): number | undefined {
|
|
52
|
+
if (!hasDocument()) return undefined
|
|
53
|
+
const probe = document as Document & {
|
|
54
|
+
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null
|
|
55
|
+
caretRangeFromPoint?: (x: number, y: number) => Range | null
|
|
56
|
+
}
|
|
57
|
+
if (typeof probe.caretPositionFromPoint === 'function') {
|
|
58
|
+
const position = probe.caretPositionFromPoint(x, y)
|
|
59
|
+
if (position !== null && position !== undefined) return position.offset
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
if (typeof probe.caretRangeFromPoint === 'function') {
|
|
63
|
+
const range = probe.caretRangeFromPoint(x, y)
|
|
64
|
+
if (range !== null && range !== undefined) return range.startOffset
|
|
65
|
+
}
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Merge partial option objects without letting an explicit `undefined` win.
|
|
71
|
+
*
|
|
72
|
+
* `{ ...defaults, ...given }` looks right and is wrong the moment a caller passes
|
|
73
|
+
* an optional field it did not fill in: the key exists, so it overwrites the
|
|
74
|
+
* default with `undefined`, and the editor then behaves as if the option had been
|
|
75
|
+
* switched off. This copies only the keys that were actually provided.
|
|
76
|
+
* @param defaults - the base.
|
|
77
|
+
* @param given - the overrides, which may be undefined.
|
|
78
|
+
* @returns the merged object.
|
|
79
|
+
*/
|
|
80
|
+
export function withDefaults<T extends object>(defaults: T, given: Partial<T> | undefined): T {
|
|
81
|
+
if (given === undefined) return { ...defaults }
|
|
82
|
+
const merged = { ...defaults }
|
|
83
|
+
for (const key of Object.keys(given) as Array<keyof T>) {
|
|
84
|
+
const value = given[key]
|
|
85
|
+
if (value !== undefined) merged[key] = value as T[keyof T]
|
|
86
|
+
}
|
|
87
|
+
return merged
|
|
88
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// ─── tooltip: what the pointer is resting on ────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// One floating element, positioned near the thing it describes and kept inside the
|
|
4
|
+
// viewport. It never takes the pointer: a tooltip that can be hovered is a tooltip
|
|
5
|
+
// that flickers, because moving the mouse onto it leaves the thing that opened it.
|
|
6
|
+
//
|
|
7
|
+
// The content is deliberately plain text, not markup. A grammar supplies strings,
|
|
8
|
+
// those strings may be a font family called `<b>` or a value containing `#`, and a
|
|
9
|
+
// tooltip that rendered them as HTML would either mis-render the text or have to
|
|
10
|
+
// escape it — both wrong for text the user typed.
|
|
11
|
+
|
|
12
|
+
import type { HoverInfo } from '../core/types.js'
|
|
13
|
+
|
|
14
|
+
/** Where a floating element is allowed to sit. */
|
|
15
|
+
export interface TooltipAnchor {
|
|
16
|
+
/** Distance from the positioning container's left edge. */
|
|
17
|
+
x: number
|
|
18
|
+
/** Distance from the positioning container's top edge. */
|
|
19
|
+
y: number
|
|
20
|
+
/** The height of the anchored line or range. */
|
|
21
|
+
height: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The hover tooltip. */
|
|
25
|
+
export class Tooltip {
|
|
26
|
+
/** The tooltip element. */
|
|
27
|
+
readonly element: HTMLDivElement
|
|
28
|
+
private readonly document: Document
|
|
29
|
+
private open = false
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param ownerDocument - the document to build in.
|
|
33
|
+
*/
|
|
34
|
+
constructor(ownerDocument: Document) {
|
|
35
|
+
this.document = ownerDocument
|
|
36
|
+
this.element = ownerDocument.createElement('div')
|
|
37
|
+
this.element.className = 'litearea-tooltip'
|
|
38
|
+
this.element.setAttribute('role', 'tooltip')
|
|
39
|
+
this.element.dataset.liteareaPart = 'tooltip'
|
|
40
|
+
this.element.dataset.open = 'false'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether the tooltip is showing. */
|
|
44
|
+
get isOpen(): boolean {
|
|
45
|
+
return this.open
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Show a hover.
|
|
50
|
+
* @param info - what to say.
|
|
51
|
+
* @param anchor - where the thing being described is.
|
|
52
|
+
* @param container - the element the tooltip is positioned against.
|
|
53
|
+
* @param viewport - the visible area to stay inside.
|
|
54
|
+
*/
|
|
55
|
+
show(
|
|
56
|
+
info: HoverInfo,
|
|
57
|
+
anchor: TooltipAnchor,
|
|
58
|
+
container: HTMLElement,
|
|
59
|
+
viewport: { width: number; height: number },
|
|
60
|
+
): void {
|
|
61
|
+
const fragment = this.document.createDocumentFragment()
|
|
62
|
+
if (info.title !== undefined) {
|
|
63
|
+
const title = this.document.createElement('div')
|
|
64
|
+
title.className = 'litearea-tooltipTitle'
|
|
65
|
+
title.textContent = info.title
|
|
66
|
+
fragment.appendChild(title)
|
|
67
|
+
}
|
|
68
|
+
if (info.detail !== undefined && info.detail !== '') {
|
|
69
|
+
const detail = this.document.createElement('div')
|
|
70
|
+
detail.className = 'litearea-tooltipDetail'
|
|
71
|
+
detail.textContent = info.detail
|
|
72
|
+
fragment.appendChild(detail)
|
|
73
|
+
}
|
|
74
|
+
if (info.body !== undefined && info.body !== '') {
|
|
75
|
+
const body = this.document.createElement('div')
|
|
76
|
+
body.className = 'litearea-tooltipBody'
|
|
77
|
+
body.textContent = info.body
|
|
78
|
+
fragment.appendChild(body)
|
|
79
|
+
}
|
|
80
|
+
this.element.replaceChildren(fragment)
|
|
81
|
+
this.open = true
|
|
82
|
+
this.element.dataset.open = 'true'
|
|
83
|
+
|
|
84
|
+
const box = this.element.getBoundingClientRect()
|
|
85
|
+
const containerBox = container.getBoundingClientRect()
|
|
86
|
+
const gap = 6
|
|
87
|
+
let top = anchor.y + anchor.height + gap
|
|
88
|
+
if (containerBox.top + top + box.height > viewport.height - gap) {
|
|
89
|
+
const above = anchor.y - box.height - gap
|
|
90
|
+
top = containerBox.top + above >= gap ? above : Math.max(0, viewport.height - gap - box.height - containerBox.top)
|
|
91
|
+
}
|
|
92
|
+
let left = anchor.x
|
|
93
|
+
const overflowRight = containerBox.left + left + box.width - (viewport.width - gap)
|
|
94
|
+
if (overflowRight > 0) left = Math.max(0, left - overflowRight)
|
|
95
|
+
this.element.style.top = `${String(Math.round(top))}px`
|
|
96
|
+
this.element.style.left = `${String(Math.round(left))}px`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Hide the tooltip. */
|
|
100
|
+
hide(): void {
|
|
101
|
+
if (!this.open) return
|
|
102
|
+
this.open = false
|
|
103
|
+
this.element.dataset.open = 'false'
|
|
104
|
+
this.element.replaceChildren()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Take the tooltip out of the document. */
|
|
108
|
+
destroy(): void {
|
|
109
|
+
this.element.remove()
|
|
110
|
+
this.element.replaceChildren()
|
|
111
|
+
}
|
|
112
|
+
}
|