@citisen/litearea 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/README.zh.md +362 -0
- package/dist/grammars.cjs +1228 -0
- package/dist/grammars.cjs.map +1 -0
- package/dist/grammars.js +1213 -0
- package/dist/grammars.js.map +1 -0
- package/dist/index.cjs +3103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +3040 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +3032 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +3010 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.cjs +453 -0
- package/dist/styles.cjs.map +1 -0
- package/dist/styles.css +432 -0
- package/dist/styles.js +447 -0
- package/dist/styles.js.map +1 -0
- package/dist/types/core/complete.d.ts +70 -0
- package/dist/types/core/complete.d.ts.map +1 -0
- package/dist/types/core/format.d.ts +34 -0
- package/dist/types/core/format.d.ts.map +1 -0
- package/dist/types/core/grammar.d.ts +14 -0
- package/dist/types/core/grammar.d.ts.map +1 -0
- package/dist/types/core/hover.d.ts +23 -0
- package/dist/types/core/hover.d.ts.map +1 -0
- package/dist/types/core/index.d.ts +12 -0
- package/dist/types/core/index.d.ts.map +1 -0
- package/dist/types/core/inspect.d.ts +34 -0
- package/dist/types/core/inspect.d.ts.map +1 -0
- package/dist/types/core/rank.d.ts +82 -0
- package/dist/types/core/rank.d.ts.map +1 -0
- package/dist/types/core/scan.d.ts +51 -0
- package/dist/types/core/scan.d.ts.map +1 -0
- package/dist/types/core/segments.d.ts +44 -0
- package/dist/types/core/segments.d.ts.map +1 -0
- package/dist/types/core/text.d.ts +113 -0
- package/dist/types/core/text.d.ts.map +1 -0
- package/dist/types/core/types.d.ts +604 -0
- package/dist/types/core/types.d.ts.map +1 -0
- package/dist/types/core/vocabulary.d.ts +82 -0
- package/dist/types/core/vocabulary.d.ts.map +1 -0
- package/dist/types/dom/create.d.ts +17 -0
- package/dist/types/dom/create.d.ts.map +1 -0
- package/dist/types/dom/editing.d.ts +89 -0
- package/dist/types/dom/editing.d.ts.map +1 -0
- package/dist/types/dom/editor.d.ts +366 -0
- package/dist/types/dom/editor.d.ts.map +1 -0
- package/dist/types/dom/index.d.ts +9 -0
- package/dist/types/dom/index.d.ts.map +1 -0
- package/dist/types/dom/mirror.d.ts +107 -0
- package/dist/types/dom/mirror.d.ts.map +1 -0
- package/dist/types/dom/overlay.d.ts +52 -0
- package/dist/types/dom/overlay.d.ts.map +1 -0
- package/dist/types/dom/popup.d.ts +95 -0
- package/dist/types/dom/popup.d.ts.map +1 -0
- package/dist/types/dom/support.d.ts +41 -0
- package/dist/types/dom/support.d.ts.map +1 -0
- package/dist/types/dom/tooltip.d.ts +39 -0
- package/dist/types/dom/tooltip.d.ts.map +1 -0
- package/dist/types/grammars/dshFont.d.ts +127 -0
- package/dist/types/grammars/dshFont.d.ts.map +1 -0
- package/dist/types/grammars/dshSentry.d.ts +84 -0
- package/dist/types/grammars/dshSentry.d.ts.map +1 -0
- package/dist/types/grammars/index.d.ts +3 -0
- package/dist/types/grammars/index.d.ts.map +1 -0
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/react/index.d.ts +91 -0
- package/dist/types/react/index.d.ts.map +1 -0
- package/dist/types/styles.d.ts +29 -0
- package/dist/types/styles.d.ts.map +1 -0
- package/docs/architecture.md +316 -0
- package/docs/completion.md +320 -0
- package/docs/grammar.md +823 -0
- package/package.json +105 -0
- package/scripts/browser-check.mjs +838 -0
- package/scripts/build-css.mjs +35 -0
- package/scripts/release.mjs +91 -0
- package/scripts/verify-package.mjs +253 -0
- package/src/core/complete.ts +286 -0
- package/src/core/format.ts +71 -0
- package/src/core/grammar.ts +40 -0
- package/src/core/hover.ts +129 -0
- package/src/core/index.ts +98 -0
- package/src/core/inspect.ts +198 -0
- package/src/core/rank.ts +317 -0
- package/src/core/scan.ts +720 -0
- package/src/core/segments.ts +185 -0
- package/src/core/text.ts +238 -0
- package/src/core/types.ts +681 -0
- package/src/core/vocabulary.ts +196 -0
- package/src/dom/create.ts +31 -0
- package/src/dom/editing.ts +213 -0
- package/src/dom/editor.ts +1143 -0
- package/src/dom/index.ts +46 -0
- package/src/dom/mirror.ts +305 -0
- package/src/dom/overlay.ts +106 -0
- package/src/dom/popup.ts +323 -0
- package/src/dom/support.ts +88 -0
- package/src/dom/tooltip.ts +112 -0
- package/src/grammars/dshFont.ts +1004 -0
- package/src/grammars/dshSentry.ts +742 -0
- package/src/grammars/index.ts +57 -0
- package/src/index.ts +122 -0
- package/src/react/index.tsx +248 -0
- package/src/styles.ts +529 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// ─── vocabulary: the four facts that must agree ─────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A closed set of words appears four times in a language-aware editor: as the
|
|
4
|
+
// syntax that accepts it, as the colour it is painted in, as the message a typo
|
|
5
|
+
// earns, and as the documentation a hover shows. Written out four times they
|
|
6
|
+
// drift — a word added to the parser and forgotten by the highlighter is the
|
|
7
|
+
// ordinary way a grammar rots — and the drift is invisible until someone types
|
|
8
|
+
// the new word and watches the editor get it wrong.
|
|
9
|
+
//
|
|
10
|
+
// So they are declared once. `defineVocabulary` returns an object the engine
|
|
11
|
+
// already knows how to consume: hand it to a `words` rule and it paints, accepts,
|
|
12
|
+
// rejects, and documents without being mentioned again.
|
|
13
|
+
//
|
|
14
|
+
// const SHAPES = defineVocabulary({
|
|
15
|
+
// id: 'shape',
|
|
16
|
+
// words: ['circle', 'rounded', 'square', 'none'],
|
|
17
|
+
// unknownMessage: '"{word}" is not a shape — expected one of {allowed}.',
|
|
18
|
+
// docs: { circle: 'A full disc, the background for a busy state.' },
|
|
19
|
+
// })
|
|
20
|
+
//
|
|
21
|
+
// { kind: 'words', words: SHAPES }
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
ResolvedVocabulary,
|
|
25
|
+
Scope,
|
|
26
|
+
Severity,
|
|
27
|
+
VocabularyContext,
|
|
28
|
+
VocabularyEntry,
|
|
29
|
+
WordsSource,
|
|
30
|
+
} from './types.js'
|
|
31
|
+
import { fillTemplate, listPhrase } from './format.js'
|
|
32
|
+
|
|
33
|
+
/** What a vocabulary is told about the document it is being resolved for. */
|
|
34
|
+
export interface VocabularySpec<State = unknown> {
|
|
35
|
+
/** A stable id, used in the diagnostic code and for debugging. */
|
|
36
|
+
id: string
|
|
37
|
+
/**
|
|
38
|
+
* The members.
|
|
39
|
+
*
|
|
40
|
+
* A function is how a set that lives outside the document reaches the grammar
|
|
41
|
+
* without the grammar holding mutable state: the installed fonts, the states
|
|
42
|
+
* the host actually has. It is called once per scan.
|
|
43
|
+
*/
|
|
44
|
+
words: readonly string[] | ((context: VocabularyContext<State>) => readonly string[])
|
|
45
|
+
/** The scope a member is painted with. Defaults to `vocabulary:<id>`. */
|
|
46
|
+
scope?: Scope | ((word: string) => Scope)
|
|
47
|
+
/** The scope a rejected word is painted with. Defaults to `'invalid'`. */
|
|
48
|
+
unknownScope?: Scope
|
|
49
|
+
/**
|
|
50
|
+
* The message a rejected word earns.
|
|
51
|
+
*
|
|
52
|
+
* Present, a word-shaped token outside the set is reported; absent, it is
|
|
53
|
+
* reported by nobody and the grammar is the poorer for it. `{word}` and
|
|
54
|
+
* `{allowed}` are substituted, the latter as a readable list.
|
|
55
|
+
*/
|
|
56
|
+
unknownMessage?: string | ((word: string, allowed: readonly string[]) => string)
|
|
57
|
+
/** How loudly a rejected word complains. Default `'error'`. */
|
|
58
|
+
unknownSeverity?: Severity
|
|
59
|
+
/** A stable code for the rejection. Defaults to `vocabulary:<id>`. */
|
|
60
|
+
unknownCode?: string
|
|
61
|
+
/**
|
|
62
|
+
* What each member explains about itself, for hover. A bare string is taken as
|
|
63
|
+
* the body.
|
|
64
|
+
*/
|
|
65
|
+
docs?: Readonly<Record<string, string | VocabularyEntry>>
|
|
66
|
+
/** A dimmed line shown beside a member in the completion list. */
|
|
67
|
+
detail?: (word: string) => string | undefined
|
|
68
|
+
/** Whether membership is case-sensitive. Default `false`. */
|
|
69
|
+
caseSensitive?: boolean
|
|
70
|
+
/**
|
|
71
|
+
* How a member is written into the document when it is accepted.
|
|
72
|
+
*
|
|
73
|
+
* Separate from the word itself because a member is not always insertable as
|
|
74
|
+
* typed: a font family containing a space has to be quoted, even though the
|
|
75
|
+
* vocabulary calls it `IBM Plex Mono`.
|
|
76
|
+
*/
|
|
77
|
+
format?: (word: string) => string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a vocabulary: the set, its colour, its rejection, and its documentation.
|
|
82
|
+
* @param spec - the declaration.
|
|
83
|
+
* @returns a resolved vocabulary, ready for a `words` rule.
|
|
84
|
+
*/
|
|
85
|
+
export function defineVocabulary<State = unknown>(
|
|
86
|
+
spec: VocabularySpec<State>,
|
|
87
|
+
): ResolvedVocabulary<State> {
|
|
88
|
+
const caseSensitive = spec.caseSensitive === true
|
|
89
|
+
const unknownScope = spec.unknownScope ?? 'invalid'
|
|
90
|
+
const defaultScope = `vocabulary:${spec.id}`
|
|
91
|
+
const unknownCode = spec.unknownCode ?? `vocabulary:${spec.id}`
|
|
92
|
+
const unknownSeverity = spec.unknownSeverity ?? 'error'
|
|
93
|
+
/** Fold a word for lookup, honouring the vocabulary's case rule. */
|
|
94
|
+
const fold = (word: string): string => (caseSensitive ? word : word.toLowerCase())
|
|
95
|
+
/** Find the member a word names, preserving the declared spelling. */
|
|
96
|
+
const memberOf = (word: string, allowed: readonly string[]): string | undefined => {
|
|
97
|
+
const needle = fold(word)
|
|
98
|
+
return allowed.find((candidate) => fold(candidate) === needle)
|
|
99
|
+
}
|
|
100
|
+
/** The declaration for one member, whichever of the two shapes it took. */
|
|
101
|
+
const docOf = (word: string): VocabularyEntry | undefined => {
|
|
102
|
+
if (spec.docs === undefined) return undefined
|
|
103
|
+
for (const [key, value] of Object.entries(spec.docs)) {
|
|
104
|
+
if (fold(key) !== fold(word)) continue
|
|
105
|
+
return typeof value === 'string' ? { body: value } : value
|
|
106
|
+
}
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
id: spec.id,
|
|
112
|
+
caseSensitive,
|
|
113
|
+
resolve: (context) => {
|
|
114
|
+
const words = typeof spec.words === 'function' ? spec.words(context) : spec.words
|
|
115
|
+
return Array.isArray(words) ? words : []
|
|
116
|
+
},
|
|
117
|
+
has: (word, context) => {
|
|
118
|
+
const words = typeof spec.words === 'function' ? spec.words(context) : spec.words
|
|
119
|
+
return memberOf(word, Array.isArray(words) ? words : []) !== undefined
|
|
120
|
+
},
|
|
121
|
+
scopeFor: (word) => {
|
|
122
|
+
if (typeof spec.scope === 'function') return spec.scope(memberOf(word, [word]) ?? word)
|
|
123
|
+
return spec.scope ?? defaultScope
|
|
124
|
+
},
|
|
125
|
+
unknownScope,
|
|
126
|
+
reject: (word, context) => {
|
|
127
|
+
if (spec.unknownMessage === undefined) return undefined
|
|
128
|
+
const allowed = typeof spec.words === 'function' ? spec.words(context) : spec.words
|
|
129
|
+
const members = Array.isArray(allowed) ? allowed : []
|
|
130
|
+
const message =
|
|
131
|
+
typeof spec.unknownMessage === 'function'
|
|
132
|
+
? spec.unknownMessage(word, members)
|
|
133
|
+
: fillTemplate(spec.unknownMessage, {
|
|
134
|
+
word,
|
|
135
|
+
allowed: listPhrase(members, { conjunction: 'or' }),
|
|
136
|
+
})
|
|
137
|
+
return { message, severity: unknownSeverity, code: unknownCode }
|
|
138
|
+
},
|
|
139
|
+
entryFor: (word) => docOf(word),
|
|
140
|
+
format: (word) => (spec.format === undefined ? word : spec.format(word)),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The members of a vocabulary, resolved for a document.
|
|
146
|
+
*
|
|
147
|
+
* A helper for the common case of a caller that has a vocabulary and wants its
|
|
148
|
+
* words, without reaching through to `resolve` and re-supplying the context.
|
|
149
|
+
* @param vocabulary - a resolved vocabulary.
|
|
150
|
+
* @param context - the document it is being resolved for.
|
|
151
|
+
* @returns the members, in declaration order.
|
|
152
|
+
*/
|
|
153
|
+
export function vocabularyWords<State>(
|
|
154
|
+
vocabulary: ResolvedVocabulary<State>,
|
|
155
|
+
context: VocabularyContext<State>,
|
|
156
|
+
): readonly string[] {
|
|
157
|
+
return vocabulary.resolve(context)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Recognize a vocabulary among the shapes a `WordsSource` may take.
|
|
162
|
+
*
|
|
163
|
+
* The test is structural rather than `instanceof`, so a grammar may hand over its
|
|
164
|
+
* own object as long as it carries the four facts — which is what lets a host
|
|
165
|
+
* wrap a vocabulary in logging, caching, or a translation layer without the
|
|
166
|
+
* engine needing to know.
|
|
167
|
+
* @param source - a words source.
|
|
168
|
+
* @returns the vocabulary, or undefined when the source is a bare list or function.
|
|
169
|
+
*/
|
|
170
|
+
export function asResolvedVocabulary<State>(
|
|
171
|
+
source: WordsSource<State>,
|
|
172
|
+
): ResolvedVocabulary<State> | undefined {
|
|
173
|
+
if (typeof source !== 'object' || source === null || Array.isArray(source)) return undefined
|
|
174
|
+
const candidate = source as { resolve?: unknown }
|
|
175
|
+
return typeof candidate.resolve === 'function' ? (source as ResolvedVocabulary<State>) : undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The members any words source stands for, whichever shape it took.
|
|
180
|
+
* @param source - a bare list, a vocabulary, or a function.
|
|
181
|
+
* @param context - the document it is being resolved for.
|
|
182
|
+
* @returns the members, or an empty list when the source resolved to nothing.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveWordsSource<State>(
|
|
185
|
+
source: WordsSource<State>,
|
|
186
|
+
context: VocabularyContext<State>,
|
|
187
|
+
): readonly string[] {
|
|
188
|
+
const vocabulary = asResolvedVocabulary(source)
|
|
189
|
+
const raw =
|
|
190
|
+
vocabulary !== undefined
|
|
191
|
+
? vocabulary.resolve(context)
|
|
192
|
+
: typeof source === 'function'
|
|
193
|
+
? source(context)
|
|
194
|
+
: source
|
|
195
|
+
return Array.isArray(raw) ? raw.filter((word) => word !== '') : []
|
|
196
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// ─── create: the one-line entry point ───────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The class exists for a host that wants to hold the instance and drive it. This
|
|
4
|
+
// exists for the far more common case of wanting an editor in an element and not
|
|
5
|
+
// much else.
|
|
6
|
+
|
|
7
|
+
import { LiteArea, type LiteAreaOptions } from './editor.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build an editor and put it in an element.
|
|
11
|
+
*
|
|
12
|
+
* The `refresh` after mounting is not a formality. A textarea that is not in the
|
|
13
|
+
* document has no layout, so it has no width, so nothing can be measured against it
|
|
14
|
+
* — and a measurement taken then is not merely imprecise, it wraps the text at every
|
|
15
|
+
* character and comes back several times too tall. The editor's own
|
|
16
|
+
* `ResizeObserver` would correct it on the next frame, which means one frame of a box
|
|
17
|
+
* at the wrong height. Measuring again here, synchronously, means the first frame is
|
|
18
|
+
* already right.
|
|
19
|
+
* @param target - where to mount it. Its contents are appended to, not replaced.
|
|
20
|
+
* @param options - the grammar, the initial text, and the behaviour to use.
|
|
21
|
+
* @returns the editor, so the caller can drive it.
|
|
22
|
+
*/
|
|
23
|
+
export function createEditor<State = unknown>(
|
|
24
|
+
target: Element,
|
|
25
|
+
options: LiteAreaOptions<State>,
|
|
26
|
+
): LiteArea<State> {
|
|
27
|
+
const editor = new LiteArea(options)
|
|
28
|
+
target.appendChild(editor.element)
|
|
29
|
+
editor.refresh()
|
|
30
|
+
return editor
|
|
31
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// ─── editing: changing a textarea without breaking its history ──────────────
|
|
2
|
+
//
|
|
3
|
+
// This is the module the whole library exists to get right.
|
|
4
|
+
//
|
|
5
|
+
// A textarea owns its own undo stack, and that stack is maintained by the
|
|
6
|
+
// browser's editing pipeline — not by the value property. Assigning to
|
|
7
|
+
// `field.value` replaces the text and DESTROYS the history, which is why a
|
|
8
|
+
// controlled component that round-trips every keystroke through React state (or a
|
|
9
|
+
// state setter of any kind) has no working Ctrl+Z. It also resets the caret to the
|
|
10
|
+
// end, which is the other half of the same mistake.
|
|
11
|
+
//
|
|
12
|
+
// So the editor never writes `value` for an edit the user could have made. It
|
|
13
|
+
// moves the selection and asks the browser to insert text, which the browser
|
|
14
|
+
// records as one undoable edit:
|
|
15
|
+
//
|
|
16
|
+
// field.setSelectionRange(from, to)
|
|
17
|
+
// document.execCommand('insertText', false, text) // ← undoable
|
|
18
|
+
//
|
|
19
|
+
// `execCommand` is deprecated, and there is no replacement. A synthetic
|
|
20
|
+
// `beforeinput` or `InputEvent` is untrusted, so the browser will not run it
|
|
21
|
+
// through the editing pipeline; `setRangeText` is the standard API and bypasses
|
|
22
|
+
// the history entirely. Every editor that supports undo on a textarea uses this
|
|
23
|
+
// call. Where it is missing the edit still happens through `setRangeText`, and the
|
|
24
|
+
// caller is told plainly that the history did not get it, so a host can decide
|
|
25
|
+
// whether that matters.
|
|
26
|
+
|
|
27
|
+
import { clamp } from '../core/text.js'
|
|
28
|
+
import { canEditThroughPipeline } from './support.js'
|
|
29
|
+
|
|
30
|
+
/** A caret or selection, as offsets into the value. */
|
|
31
|
+
export interface TextSelection {
|
|
32
|
+
/** The anchor end. Equal to `end` for a caret. */
|
|
33
|
+
start: number
|
|
34
|
+
/** The moving end. */
|
|
35
|
+
end: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How an edit reached the document.
|
|
40
|
+
*
|
|
41
|
+
* `pipeline` means the browser recorded it and Ctrl+Z will undo it. `direct` means
|
|
42
|
+
* the text changed and the history did not, which only happens where
|
|
43
|
+
* `document.execCommand` is unavailable — a test environment, or a browser that
|
|
44
|
+
* has finally removed it.
|
|
45
|
+
*/
|
|
46
|
+
export type EditOutcome = 'pipeline' | 'direct' | 'unchanged'
|
|
47
|
+
|
|
48
|
+
/** Read the caret or selection out of a field. */
|
|
49
|
+
export function readSelection(field: HTMLTextAreaElement): TextSelection {
|
|
50
|
+
const start = field.selectionStart ?? 0
|
|
51
|
+
const end = field.selectionEnd ?? start
|
|
52
|
+
return { start, end }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Put a caret or selection into a field.
|
|
57
|
+
*
|
|
58
|
+
* Setting a selection moves the caret and nothing else: it does not touch the
|
|
59
|
+
* value, so it cannot disturb the undo stack. This is what the editor uses instead
|
|
60
|
+
* of re-rendering text to reposition the caret — the re-render was the bug.
|
|
61
|
+
*
|
|
62
|
+
* A range given the wrong way round is ordered rather than collapsed. `start` and
|
|
63
|
+
* `end` are named for a caret and a selection, and a caller that computes them from a
|
|
64
|
+
* backwards drag has still asked for that text to be selected; quietly selecting
|
|
65
|
+
* nothing at the far end would be a silent wrong answer.
|
|
66
|
+
* @param field - the textarea.
|
|
67
|
+
* @param start - the anchor offset.
|
|
68
|
+
* @param end - the moving offset; defaults to `start`.
|
|
69
|
+
*/
|
|
70
|
+
export function writeSelection(
|
|
71
|
+
field: HTMLTextAreaElement,
|
|
72
|
+
start: number,
|
|
73
|
+
end: number = start,
|
|
74
|
+
): void {
|
|
75
|
+
const length = field.value.length
|
|
76
|
+
const from = clamp(Math.min(start, end), 0, length)
|
|
77
|
+
const to = clamp(Math.max(start, end), from, length)
|
|
78
|
+
field.setSelectionRange(from, to)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The line height in force on a field, falling back to the computed font size. */
|
|
82
|
+
export function fieldLineHeight(field: HTMLTextAreaElement): number {
|
|
83
|
+
const styles = field.ownerDocument.defaultView?.getComputedStyle(field)
|
|
84
|
+
if (styles === null || styles === undefined) return 0
|
|
85
|
+
const lineHeight = Number.parseFloat(styles.lineHeight)
|
|
86
|
+
if (Number.isFinite(lineHeight)) return lineHeight
|
|
87
|
+
const fontSize = Number.parseFloat(styles.fontSize)
|
|
88
|
+
return Number.isFinite(fontSize) ? fontSize * 1.2 : 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Replace a range through the browser's editing pipeline, so it can be undone.
|
|
93
|
+
*
|
|
94
|
+
* The field is focused first because the pipeline only runs on the focused
|
|
95
|
+
* element, and a completion accepted by mouse click would otherwise arrive after
|
|
96
|
+
* the click had already blurred the field.
|
|
97
|
+
* @param field - the textarea.
|
|
98
|
+
* @param from - the first offset to replace.
|
|
99
|
+
* @param to - the offset after the last.
|
|
100
|
+
* @param text - what to put there. The empty string deletes.
|
|
101
|
+
* @returns how the edit landed.
|
|
102
|
+
*/
|
|
103
|
+
export function replaceThroughPipeline(
|
|
104
|
+
field: HTMLTextAreaElement,
|
|
105
|
+
from: number,
|
|
106
|
+
to: number,
|
|
107
|
+
text: string,
|
|
108
|
+
): EditOutcome {
|
|
109
|
+
const start = clamp(Math.min(from, to), 0, field.value.length)
|
|
110
|
+
const end = clamp(Math.max(from, to), start, field.value.length)
|
|
111
|
+
if (start === end && text === '') return 'unchanged'
|
|
112
|
+
|
|
113
|
+
if (field.ownerDocument.activeElement !== field) field.focus({ preventScroll: true })
|
|
114
|
+
writeSelection(field, start, end)
|
|
115
|
+
|
|
116
|
+
const before = field.value
|
|
117
|
+
if (canEditThroughPipeline()) {
|
|
118
|
+
try {
|
|
119
|
+
field.ownerDocument.execCommand('insertText', false, text)
|
|
120
|
+
} catch {
|
|
121
|
+
// Firefox throws rather than returning false for some input types. Falling
|
|
122
|
+
// through to the direct path is the right answer either way.
|
|
123
|
+
}
|
|
124
|
+
// The return value is not trustworthy across browsers, and it does not need to
|
|
125
|
+
// be: the VALUE decides. A change we did not make ourselves is one the
|
|
126
|
+
// browser's history already knows about, which is the whole point.
|
|
127
|
+
if (field.value !== before) return 'pipeline'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
field.setRangeText(text, start, end, 'end')
|
|
131
|
+
if (field.value === before) return 'unchanged'
|
|
132
|
+
dispatchInput(field, text)
|
|
133
|
+
return 'direct'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Fire the event the browser would have fired, for the direct path only.
|
|
138
|
+
*
|
|
139
|
+
* The pipeline path needs no help: the browser dispatches `input` itself, which is
|
|
140
|
+
* exactly why the editor can treat both paths the same way downstream.
|
|
141
|
+
* @param field - the textarea.
|
|
142
|
+
* @param text - what was written.
|
|
143
|
+
*/
|
|
144
|
+
export function dispatchInput(field: HTMLTextAreaElement, text: string): void {
|
|
145
|
+
const view = field.ownerDocument.defaultView
|
|
146
|
+
const InputEventCtor = view === null ? undefined : (view as Window & { InputEvent?: typeof InputEvent }).InputEvent
|
|
147
|
+
if (typeof InputEventCtor === 'function') {
|
|
148
|
+
field.dispatchEvent(
|
|
149
|
+
new InputEventCtor('input', { bubbles: true, inputType: 'insertText', data: text }),
|
|
150
|
+
)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
field.dispatchEvent(new Event('input', { bubbles: true }))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Undo, through the browser's own history.
|
|
158
|
+
*
|
|
159
|
+
* Exposed so a host can offer a button without reimplementing a stack, and so the
|
|
160
|
+
* browser harness can drive the same path a keyboard shortcut drives.
|
|
161
|
+
* @param field - the textarea.
|
|
162
|
+
* @returns whether the call was possible at all. It cannot report whether anything
|
|
163
|
+
* was actually undone, because no browser exposes that.
|
|
164
|
+
*/
|
|
165
|
+
export function undoField(field: HTMLTextAreaElement): boolean {
|
|
166
|
+
if (!canEditThroughPipeline()) return false
|
|
167
|
+
if (field.ownerDocument.activeElement !== field) field.focus({ preventScroll: true })
|
|
168
|
+
try {
|
|
169
|
+
return field.ownerDocument.execCommand('undo')
|
|
170
|
+
} catch {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Redo, through the browser's own history.
|
|
177
|
+
* @param field - the textarea.
|
|
178
|
+
* @returns whether the call was possible at all.
|
|
179
|
+
*/
|
|
180
|
+
export function redoField(field: HTMLTextAreaElement): boolean {
|
|
181
|
+
if (!canEditThroughPipeline()) return false
|
|
182
|
+
if (field.ownerDocument.activeElement !== field) field.focus({ preventScroll: true })
|
|
183
|
+
try {
|
|
184
|
+
return field.ownerDocument.execCommand('redo')
|
|
185
|
+
} catch {
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Write a whole document into a field.
|
|
192
|
+
*
|
|
193
|
+
* Two paths, and the difference matters. `preserveHistory` selects everything and
|
|
194
|
+
* inserts through the pipeline, so the replacement is one undoable edit and Ctrl+Z
|
|
195
|
+
* brings the previous text back — the right behaviour for a Reset button. Without
|
|
196
|
+
* it the value property is assigned, which is faster and clears the history, which
|
|
197
|
+
* is the right behaviour when a host is loading a different document entirely.
|
|
198
|
+
* @param field - the textarea.
|
|
199
|
+
* @param next - the new text.
|
|
200
|
+
* @param preserveHistory - whether the change should be undoable.
|
|
201
|
+
* @returns how the write landed.
|
|
202
|
+
*/
|
|
203
|
+
export function writeDocument(
|
|
204
|
+
field: HTMLTextAreaElement,
|
|
205
|
+
next: string,
|
|
206
|
+
preserveHistory = false,
|
|
207
|
+
): EditOutcome {
|
|
208
|
+
if (field.value === next) return 'unchanged'
|
|
209
|
+
if (preserveHistory) return replaceThroughPipeline(field, 0, field.value.length, next)
|
|
210
|
+
field.value = next
|
|
211
|
+
writeSelection(field, next.length, next.length)
|
|
212
|
+
return 'direct'
|
|
213
|
+
}
|